• JUC - 多线程之Callable;集合类线程不安全(二)


    一、Callable

    Callable接口类似于Runnable ,因为它们都是为其实例可能由另一个线程执行的类设计的。 然而Runnable不返回结果,也不能抛出被检查的异常

    Callable是创建线程的第三种方式,是一个函数式接口

    1. /**
    2. * A task that returns a result and may throw an exception.
    3. * Implementors define a single method with no arguments called
    4. * {@code call}.
    5. *
    6. *

      The {@code Callable} interface is similar to {@link

    7. * java.lang.Runnable}, in that both are designed for classes whose
    8. * instances are potentially executed by another thread. A
    9. * {@code Runnable}, however, does not return a result and cannot
    10. * throw a checked exception.
    11. *
    12. *

      The {@link Executors} class contains utility methods to

    13. * convert from other common forms to {@code Callable} classes.
    14. *
    15. * @see Executor
    16. * @since 1.5
    17. * @author Doug Lea
    18. * @param the result type of method {@code call}
    19. */
    20. @FunctionalInterface
    21. public interface Callable {
    22. /**
    23. * Computes a result, or throws an exception if unable to do so.
    24. *
    25. * @return computed result
    26. * @throws Exception if unable to compute a result
    27. */
    28. V call() throws Exception;
    29. }

    使用Callable接口创建线程时,发现Thread中构造方法并没有Callable参数

      

    但是Java中提供了一实现Runnable接口的实现类 FutureTask

    1、Future接口

    • Future是一个接口,代表了一个异步计算的结果。接口中的方法用来检查计算是否完成、等待完成和得到计算的结果。
    • 当计算完成后,只能通过get()方法得到结果,get方法会阻塞直到结果准备好了。
    • 如果想取消,那么调用cancel()方法。其他方法用于确定任务是正常完成还是取消了。
    • 一旦计算完成了,那么这个计算就不能被取消。

    2、FutureTask类

    • FutureTask类实现了RunnableFuture接口,而RunnnableFuture接口继承了Runnable和Future接口,所以说FutureTask是一个提供异步计算的结果的任务。
    • FutureTask可以用来包装Callable或者Runnbale对象。因为FutureTask实现了Runnable接口,所以FutureTask也可以被提交给Executor(如上面例子那样)

    FutureTask 实现了 RunnableFuture 接口,RunnableFuture 接口继承自 Runnable 接口

    1. public class FutureTask implements RunnableFuture {
    2. public FutureTask(Callable callable) {
    3. if (callable == null)
    4. throw new NullPointerException();
    5. this.callable = callable;
    6. this.state = NEW; // ensure visibility of callable
    7. }
    8. public FutureTask(Runnable runnable, V result) {
    9. this.callable = Executors.callable(runnable, result);
    10. this.state = NEW; // ensure visibility of callable
    11. }
    12. }
    13. public interface RunnableFuture extends Runnable, Future {
    14. void run();
    15. }

    因此我们可以先创建一个 FutureTask 类,将 Callable 参数传进去,再将 FutureTask 作为参数传入创建 Thread类中

    1. import java.util.concurrent.Callable;
    2. import java.util.concurrent.ExecutionException;
    3. import java.util.concurrent.FutureTask;
    4. public class CallableTest {
    5. public static void main(String[] args) throws ExecutionException, InterruptedException {
    6. // new Thread(new Runnable()).start();
    7. // new Thread(new FutureTask()).start();
    8. // new Thread(new FutureTask(Callable)).start();
    9. MyCallable myCallable = new MyCallable();
    10. FutureTask futureTask = new FutureTask(myCallable);
    11. new Thread(futureTask,"A").start();
    12. new Thread(futureTask,"B").start();
    13. Integer o = (Integer) futureTask.get(); //这个get 方法可能会产生阻塞!把他放到最后
    14. // 或者使用异步通信来处理!
    15. System.out.println(o);
    16. }
    17. }
    18. class MyCallable implements Callable{
    19. @Override
    20. public Integer call() throws Exception {
    21. // 耗时操作
    22. System.out.println("call()");
    23. return 200;
    24. }
    25. }

    输出

    1. call()
    2. 200

    注:

    1、Callable接口会有缓存,开启多个线程但是只返回一个 输出

    2、返回结果会阻塞线程,比较耗时,尽量放在代码最后 或者使用异步通信处理

    Callable 和 Runnable 区别

    1. //Callable 接口
    2. public interface Callable {
    3. V call() throws Exception;
    4. }
    5. // Runnable 接口
    6. public interface Runnable {
    7. public abstract void run();
    8. }

    1、Callable规定的方法是call(),Runnable规定的方法是run().

    2、Callable的任务执行后可返回值,而Runnable的任务是不能返回值的

    3、call方法可以抛出异常,run方法不可以

    4、运行Callable任务可以拿到一个Future对象,Future 表示异步计算的结果(executorService.submit(Runnable task) 也会返回future, 但是没有future的效果 )

    二、集合类线程不安全

    (一)List

    一般在多线程下,使用List list = new ArrayList<>(); 会触发 

    ArrayList是非线程安全的,在多线程的情况下,向list插入数据的时候,可能会造成数据丢失的情况。并且一个线程在遍历List,另一个线程修改List,会报ConcurrentModificationException(并发修改异常)错误

    java.util.ConcurrentModificationException         并发修改异常

    一般有三种解决方案

    1、使用 Ventor

    Vector是一个线程安全的List,但是它的线程安全实现方式是对所有操作都加上了synchronized关键字,这种方式严重影响效率.所以并不推荐使用Vector

    2、使用 Collections.synchronizedList(List list)

    首先 Collections.synchronizedList(new ArrayList<>());

    1. public static List synchronizedList(List list) {
    2. return (list instanceof RandomAccess ?
    3. new SynchronizedRandomAccessList<>(list) :
    4. new SynchronizedList<>(list));
    5. }

     这个方法回根据你传入的List是否实现RandomAccess这个接口来返回的SynchronizedRandomAccessList还是SynchronizedList

    SynchronizedList源码

    1. static class SynchronizedList
    2. extends SynchronizedCollection
    3. implements List {
    4. private static final long serialVersionUID = -7754090372962971524L;
    5. final List list;
    6. SynchronizedList(List list) {
    7. super(list);
    8. this.list = list;
    9. }
    10. SynchronizedList(List list, Object mutex) {
    11. super(list, mutex);
    12. this.list = list;
    13. }
    14. public boolean equals(Object o) {
    15. if (this == o)
    16. return true;
    17. synchronized (mutex) {return list.equals(o);}
    18. }
    19. public int hashCode() {
    20. synchronized (mutex) {return list.hashCode();}
    21. }
    22. public E get(int index) {
    23. synchronized (mutex) {return list.get(index);}
    24. }
    25. public E set(int index, E element) {
    26. synchronized (mutex) {return list.set(index, element);}
    27. }
    28. public void add(int index, E element) {
    29. synchronized (mutex) {list.add(index, element);}
    30. }
    31. public E remove(int index) {
    32. synchronized (mutex) {return list.remove(index);}
    33. }
    34. public int indexOf(Object o) {
    35. synchronized (mutex) {return list.indexOf(o);}
    36. }
    37. public int lastIndexOf(Object o) {
    38. synchronized (mutex) {return list.lastIndexOf(o);}
    39. }
    40. public boolean addAll(int index, Collection c) {
    41. synchronized (mutex) {return list.addAll(index, c);}
    42. }
    43. public ListIterator listIterator() {
    44. return list.listIterator(); // Must be manually synched by user
    45. }
    46. public ListIterator listIterator(int index) {
    47. return list.listIterator(index); // Must be manually synched by user
    48. }
    49. public List subList(int fromIndex, int toIndex) {
    50. synchronized (mutex) {
    51. return new SynchronizedList<>(list.subList(fromIndex, toIndex),
    52. mutex);
    53. }
    54. }
    55. @Override
    56. public void replaceAll(UnaryOperator operator) {
    57. synchronized (mutex) {list.replaceAll(operator);}
    58. }
    59. @Override
    60. public void sort(Comparatorsuper E> c) {
    61. synchronized (mutex) {list.sort(c);}
    62. }
    63. ... ...
    64. }

    执行add()等方法的时候加了synchronized关键字,但是listIterator(),iterator()方法却没有加

    3、使用 CopyOnWriteArrayList

    CopyOnWriteArrayList 底层是数组实现的,主要有以下两个变量

    1. public class CopyOnWriteArrayList
    2. implements List, RandomAccess, Cloneable, java.io.Serializable {
    3. /** The lock protecting all mutators */
    4. final transient ReentrantLock lock = new ReentrantLock();
    5. /** The array, accessed only via getArray/setArray. */
    6. private transient volatile Object[] array;
    7. }

    1、lock:ReentrantLock,独占锁,多线程运行的情况下,只有一个线程会获得这个锁,只有释放锁后其他线程才能获得

    2、array:存放数据的数组,关键是被volatile修饰了,被volatile修饰,就保证了可见性,也就是一个线程修改后,其他线程立即可见 

    CopyOnWriteArrayList原理

    1、CopyOnWriteArrayList实现了List接口,因此它是一个队列

    2、CopyOnWriteArrayList包含了成员lock。每一个CopyOnWriteArrayList都和一个监视器锁lock绑定,通过lock,实现了对CopyOnWriteArrayList的互斥访问

    3、CopyOnWriteArrayList包含了成员array数组,这说明CopyOnWriteArrayList本质上通过数组实现的

    4、CopyOnWriteArrayList的“动态数组”机制 -- 它内部有个“volatile数组”(array)来保持数据。在“添加/修改/删除”数据时,都会新建一个数组,并将更新后的数据拷贝到新建的数组中,最后再将该数组赋值给“volatile数组”。这就是它叫做CopyOnWriteArrayList的原因!CopyOnWriteArrayList就是通过这种方式实现的动态数组;不过正由于它在“添加/修改/删除”数据时,都会新建数组,所以涉及到修改数据的操作,CopyOnWriteArrayList效率很 低;但是单单只是进行遍历查找的话,效率比较高

    5、CopyOnWriteArrayList的“线程安全”机制 -- 是通过volatile和监视器锁Synchrnoized来实现的

    6、CopyOnWriteArrayList是通过“volatile数组”来保存数据的。一个线程读取volatile数组时,总能看到其它线程对该volatile变量最后的写入;就这样,通过volatile提供了“读取到的数据总是最新的”这个机制的 保证

    7、CopyOnWriteArrayList通过监视器锁Synchrnoized来保护数据。在“添加/修改/删除”数据时,会先“获取监视器锁”,再修改完毕之后,先将数据更新到“volatile数组”中,然后再“释放互斥锁”;这样,就达到了保护数据的目的

    可以看到 add()方法使用 lock进行加锁

    1. /**
    2. * Appends the specified element to the end of this list.
    3. *
    4. * @param e element to be appended to this list
    5. * @return {@code true} (as specified by {@link Collection#add})
    6. */
    7. public boolean add(E e) {
    8. final ReentrantLock lock = this.lock;
    9. lock.lock();
    10. try {
    11. Object[] elements = getArray();
    12. int len = elements.length;
    13. Object[] newElements = Arrays.copyOf(elements, len + 1);
    14. newElements[len] = e;
    15. setArray(newElements);
    16. return true;
    17. } finally {
    18. lock.unlock();
    19. }
    20. }

    CopyOnWriteArrayList 添加数组的步骤如下:

    1、获得独占锁,将添加功能加锁

    2、获取原来的数组,并得到其长度

    3、创建一个长度为原来数组长度+1的数组,并拷贝原来的元素给新数组

    4、追加元素到新数组末尾

    5、指向新数组

    6、释放锁

    这个过程是线程安全的,写入时复制(COW)的核心思想就是每次修改的时候拷贝一个新的资源去修改,add()方法再拷贝新资源的时候将数组容量+1,这样虽然每次添加元素都会浪费一定的空间,但是数组的长度正好是元素的长度,也在一定程度上节省了扩容的开销

    1. /**
    2. * List 线程不安全
    3. * java.util.ConcurrentModificationException 并发修改异常
    4. */
    5. public class ListTest {
    6. public static void main(String[] args) {
    7. // 单线程下安全;并发下 线程不安全
    8. //List list = new ArrayList<>();
    9. /* 解决方案
    10. 1、List list = new Vector<>(); // 线程安全,源码中方法都有 synchronized 修饰
    11. 2、List list = Collections.synchronizedList(new ArrayList<>());
    12. 3、List list = new CopyOnWriteArrayList<>();
    13. CopyOnWrite 写入时复制(COW);计算机程序设计领域的一种优化策略
    14. 多个线程调用的时候,list,读取的时候,固定的,写入(覆盖);在写入的时候避免覆盖,造成数据问题
    15. 读写分离
    16. * */
    17. //List list = new Vector<>(); // 线程安全,源码中方法都有 synchronized 修饰
    18. //List list = Collections.synchronizedList(new ArrayList<>());
    19. List list = new CopyOnWriteArrayList<>();
    20. for (int i = 0; i < 10; i++) {
    21. new Thread(() -> {
    22. list.add(UUID.randomUUID().toString().substring(0,5));
    23. System.out.println(list);
    24. },String.valueOf(i)).start();
    25. }
    26. }
    27. }

    (二)Set

    1. /**
    2. * java.util.ConcurrentModificationException
    3. */
    4. public class SetTest {
    5. public static void main(String[] args) {
    6. //Set set = new HashSet<>();
    7. //Set set = Collections.synchronizedSet(new HashSet<>());
    8. Set set = new CopyOnWriteArraySet<>();
    9. for (int i = 1; i <=10 ; i++) {
    10. new Thread(()->{
    11. set.add(UUID.randomUUID().toString().substring(0,5));
    12. System.out.println(set);
    13. },String.valueOf(i)).start();
    14. }
    15. }
    16. }

    (三)Map

    1. // java.util.ConcurrentModificationException
    2. public class MapTest {
    3. public static void main(String[] args) {
    4. // 默认等价于 new HashMap<>(16,0.75);
    5. // Map map = new HashMap<>();
    6. Map map = new ConcurrentHashMap<>();
    7. for (int i = 1; i <=10; i++) {
    8. new Thread(()->{
    9. map.put(Thread.currentThread().getName(),UUID.randomUUID().toString().substring(0,5));
    10. System.out.println(map);
    11. },String.valueOf(i)).start();
    12. }
    13. }
    14. }

     ConcurrentHashMap源码分析

    1、添加元素put/putVal方法

    1. public V put(K key, V value) {
    2. return putVal(key, value, false);
    3. }
    4. final V putVal(K key, V value, boolean onlyIfAbsent) {
    5. //如果有空值或者空键,直接抛异常
    6. if (key == null || value == null) throw new NullPointerException();
    7. //基于key计算hash值,并进行一定的扰动
    8. int hash = spread(key.hashCode());
    9. //记录某个桶上元素的个数,如果超过8个,会转成红黑树
    10. int binCount = 0;
    11. for (Node[] tab = table;;) {
    12. Node f; int n, i, fh;
    13. //如果数组还未初始化,先对数组进行初始化
    14. if (tab == null || (n = tab.length) == 0)
    15. tab = initTable();
    16. //如果hash计算得到的桶位置没有元素,利用cas将元素添加
    17. else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
    18. //cas+自旋(和外侧的for构成自旋循环),保证元素添加安全
    19. if (casTabAt(tab, i, null,
    20. new Node(hash, key, value, null)))
    21. break; // no lock when adding to empty bin
    22. }
    23. //如果hash计算得到的桶位置元素的hash值为MOVED,证明正在扩容,那么协助扩容
    24. else if ((fh = f.hash) == MOVED)
    25. tab = helpTransfer(tab, f);
    26. else {
    27. //hash计算的桶位置元素不为空,且当前没有处于扩容操作,进行元素添加
    28. V oldVal = null;
    29. //对当前桶进行加锁,保证线程安全,执行元素添加操作
    30. synchronized (f) {
    31. if (tabAt(tab, i) == f) {
    32. //普通链表节点
    33. if (fh >= 0) {
    34. binCount = 1;
    35. for (Node e = f;; ++binCount) {
    36. K ek;
    37. if (e.hash == hash &&
    38. ((ek = e.key) == key ||
    39. (ek != null && key.equals(ek)))) {
    40. oldVal = e.val;
    41. if (!onlyIfAbsent)
    42. e.val = value;
    43. break;
    44. }
    45. Node pred = e;
    46. if ((e = e.next) == null) {
    47. pred.next = new Node(hash, key,
    48. value, null);
    49. break;
    50. }
    51. }
    52. }
    53. //树节点,将元素添加到红黑树中
    54. else if (f instanceof TreeBin) {
    55. Node p;
    56. binCount = 2;
    57. if ((p = ((TreeBin)f).putTreeVal(hash, key,
    58. value)) != null) {
    59. oldVal = p.val;
    60. if (!onlyIfAbsent)
    61. p.val = value;
    62. }
    63. }
    64. }
    65. }
    66. if (binCount != 0) {
    67. //链表长度大于/等于8,将链表转成红黑树
    68. if (binCount >= TREEIFY_THRESHOLD)
    69. treeifyBin(tab, i);
    70. //如果是重复键,直接将旧值返回
    71. if (oldVal != null)
    72. return oldVal;
    73. break;
    74. }
    75. }
    76. }
    77. //添加的是新元素,维护集合长度,并判断是否要进行扩容操作
    78. addCount(1L, binCount);
    79. return null;
    80. }

    需要添加元素时,会针对当前元素所对应的桶位进行加锁操作,这样一方面保证元素添加时,多线程的安全,同时对某个桶位加锁不会影响其他桶位的操作,进一步提升多线程的并发效率

    2、数组初始化,initTable方法

    1. private final Node[] initTable() {
    2. Node[] tab; int sc;
    3. //cas+自旋,保证线程安全,对数组进行初始化操作
    4. while ((tab = table) == null || tab.length == 0) {
    5. //如果sizeCtl的值(-1)小于0,说明此时正在初始化, 让出cpu
    6. if ((sc = sizeCtl) < 0)
    7. Thread.yield(); // lost initialization race; just spin
    8. //cas修改sizeCtl的值为-1,修改成功,进行数组初始化,失败,继续自旋
    9. else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
    10. try {
    11. if ((tab = table) == null || tab.length == 0) {
    12. //sizeCtl为0,取默认长度16,否则去sizeCtl的值
    13. int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    14. @SuppressWarnings("unchecked")
    15. //基于初始长度,构建数组对象
    16. Node[] nt = (Node[])new Node[n];
    17. table = tab = nt;
    18. //计算扩容阈值,并赋值给sc
    19. sc = n - (n >>> 2);
    20. }
    21. } finally {
    22. //将扩容阈值,赋值给sizeCtl
    23. sizeCtl = sc;
    24. }
    25. break;
    26. }
    27. }
    28. return tab;
    29. }

    3、数组扩容

    1. private final void transfer(Node[] tab, Node[] nextTab) {
    2. int n = tab.length, stride;
    3. //如果是多cpu,那么每个线程划分任务,最小任务量是16个桶位的迁移
    4. if ((stride = (NCPU > 1) ? (n >>> 3) / NCPU : n) < MIN_TRANSFER_STRIDE)
    5. stride = MIN_TRANSFER_STRIDE; // subdivide range
    6. //如果是扩容线程,此时新数组为null
    7. if (nextTab == null) { // initiating
    8. try {
    9. @SuppressWarnings("unchecked")
    10. //两倍扩容创建新数组
    11. Node[] nt = (Node[])new Node[n << 1];
    12. nextTab = nt;
    13. } catch (Throwable ex) { // try to cope with OOME
    14. sizeCtl = Integer.MAX_VALUE;
    15. return;
    16. }
    17. nextTable = nextTab;
    18. //记录线程开始迁移的桶位,从后往前迁移
    19. transferIndex = n;
    20. }
    21. //记录新数组的末尾
    22. int nextn = nextTab.length;
    23. //已经迁移的桶位,会用这个节点占位(这个节点的hash值为-1--MOVED)
    24. ForwardingNode fwd = new ForwardingNode(nextTab);
    25. boolean advance = true;
    26. boolean finishing = false; // to ensure sweep before committing nextTab
    27. for (int i = 0, bound = 0;;) {
    28. Node f; int fh;
    29. while (advance) {
    30. int nextIndex, nextBound;
    31. //i记录当前正在迁移桶位的索引值
    32. //bound记录下一次任务迁移的开始桶位
    33. //--i >= bound 成立表示当前线程分配的迁移任务还没有完成
    34. if (--i >= bound || finishing)
    35. advance = false;
    36. //没有元素需要迁移 -- 后续会去将扩容线程数减1,并判断扩容是否完成
    37. else if ((nextIndex = transferIndex) <= 0) {
    38. i = -1;
    39. advance = false;
    40. }
    41. //计算下一次任务迁移的开始桶位,并将这个值赋值给transferIndex
    42. else if (U.compareAndSwapInt
    43. (this, TRANSFERINDEX, nextIndex,
    44. nextBound = (nextIndex > stride ?
    45. nextIndex - stride : 0))) {
    46. bound = nextBound;
    47. i = nextIndex - 1;
    48. advance = false;
    49. }
    50. }
    51. //如果没有更多的需要迁移的桶位,就进入该if
    52. if (i < 0 || i >= n || i + n >= nextn) {
    53. int sc;
    54. //扩容结束后,保存新数组,并重新计算扩容阈值,赋值给sizeCtl
    55. if (finishing) {
    56. nextTable = null;
    57. table = nextTab;
    58. sizeCtl = (n << 1) - (n >>> 1);
    59. return;
    60. }
    61. //扩容任务线程数减1
    62. if (U.compareAndSwapInt(this, SIZECTL, sc = sizeCtl, sc - 1)) {
    63. //判断当前所有扩容任务线程是否都执行完成
    64. if ((sc - 2) != resizeStamp(n) << RESIZE_STAMP_SHIFT)
    65. return;
    66. //所有扩容线程都执行完,标识结束
    67. finishing = advance = true;
    68. i = n; // recheck before commit
    69. }
    70. }
    71. //当前迁移的桶位没有元素,直接在该位置添加一个fwd节点
    72. else if ((f = tabAt(tab, i)) == null)
    73. advance = casTabAt(tab, i, null, fwd);
    74. //当前节点已经被迁移
    75. else if ((fh = f.hash) == MOVED)
    76. advance = true; // already processed
    77. else {
    78. //当前节点需要迁移,加锁迁移,保证多线程安全
    79. //此处迁移逻辑和jdk7的ConcurrentHashMap相同,不再赘述
    80. synchronized (f) {
    81. if (tabAt(tab, i) == f) {
    82. Node ln, hn;
    83. if (fh >= 0) {
    84. int runBit = fh & n;
    85. Node lastRun = f;
    86. for (Node p = f.next; p != null; p = p.next) {
    87. int b = p.hash & n;
    88. if (b != runBit) {
    89. runBit = b;
    90. lastRun = p;
    91. }
    92. }
    93. if (runBit == 0) {
    94. ln = lastRun;
    95. hn = null;
    96. }
    97. else {
    98. hn = lastRun;
    99. ln = null;
    100. }
    101. for (Node p = f; p != lastRun; p = p.next) {
    102. int ph = p.hash; K pk = p.key; V pv = p.val;
    103. if ((ph & n) == 0)
    104. ln = new Node(ph, pk, pv, ln);
    105. else
    106. hn = new Node(ph, pk, pv, hn);
    107. }
    108. setTabAt(nextTab, i, ln);
    109. setTabAt(nextTab, i + n, hn);
    110. setTabAt(tab, i, fwd);
    111. advance = true;
    112. }
    113. else if (f instanceof TreeBin) {
    114. TreeBin t = (TreeBin)f;
    115. TreeNode lo = null, loTail = null;
    116. TreeNode hi = null, hiTail = null;
    117. int lc = 0, hc = 0;
    118. for (Node e = t.first; e != null; e = e.next) {
    119. int h = e.hash;
    120. TreeNode p = new TreeNode
    121. (h, e.key, e.val, null, null);
    122. if ((h & n) == 0) {
    123. if ((p.prev = loTail) == null)
    124. lo = p;
    125. else
    126. loTail.next = p;
    127. loTail = p;
    128. ++lc;
    129. }
    130. else {
    131. if ((p.prev = hiTail) == null)
    132. hi = p;
    133. else
    134. hiTail.next = p;
    135. hiTail = p;
    136. ++hc;
    137. }
    138. }
    139. ln = (lc <= UNTREEIFY_THRESHOLD) ? untreeify(lo) :
    140. (hc != 0) ? new TreeBin(lo) : t;
    141. hn = (hc <= UNTREEIFY_THRESHOLD) ? untreeify(hi) :
    142. (lc != 0) ? new TreeBin(hi) : t;
    143. setTabAt(nextTab, i, ln);
    144. setTabAt(nextTab, i + n, hn);
    145. setTabAt(tab, i, fwd);
    146. advance = true;
    147. }
    148. }
    149. }
    150. }
    151. }
    152. }
  • 相关阅读:
    迪克森电荷泵
    T-Rex2: Towards Generic Object Detection via Text-Visual Prompt Synergy论文解读
    [Linux]进程信号(阻塞信号 | 信号集操作函数 | 信号捕捉 | 可重入函数 | volatile关键字)
    win11下安装mysql
    【matlab】智能优化算法优化BP神经网络
    【★★★★★ 第1章 绪论总结笔记 2022 9.10】
    使用Dockerfile生成docker镜像和容器的方法记录
    【探索Linux】—— 强大的命令行工具 P.11(基础IO,文件操作)
    【附源码】计算机毕业设计JAVA幼儿健康管理系统
    搭建伪分布式Hadoop
  • 原文地址:https://blog.csdn.net/MinggeQingchun/article/details/127365437