java知识系列之并发基础

谈及到java中并发相关知识的时候,总是围绕多线程展开的。那么先看一下进程与线程的东西

进程与线程

  1. 进程与线程
    • 进程:每个进程都有独立的代码和数据空间(进程上下文),进程间的切换会有较大的开销,一个进程包含1–n个线程。(进程是资源分配的最小单位)
    • 线程:同一类线程共享代码和数据空间,每个线程有独立的运行栈和程序计数器(PC),线程切换开销小。(线程是cpu调度的最小单位)
    • 线程和进程一样分为五个阶段:创建、就绪、运行、阻塞、终止。
      • 创建:新创建了一个线程对象。
      • 就绪:线程对象创建后,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。
      • 运行:就绪状态的线程获取了CPU,执行程序代码。
      • 阻塞
        • 等待阻塞:运行的线程执行wait(),JVM会把该线程放入等待池中(wait()会释放掉持有的锁)
        • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中。
        • 其他阻塞: 运行的线程执行sleep()或join()方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。(注意,sleep是不会释放持有的锁)
      • 终止:线程执行完了或者因异常退出了run()方法,该线程结束生命周期。
    • 线程优先级:
      • Java线程有优先级,优先级高的线程会获得较多的运行机会
      • 优先级用整数表示,取值范围是1~10;
        • MAX_PRIORITY:10
        • NORM_PRIORITY:5
        • MIN_PRIORITY:1
    • 线程sleep:Thread类方法;使线程转到阻塞状态;睡眠结束后,就转为就绪(Runnable)状态。不会释放持有的锁,只是让出了cpu时间
    • 线程wait:Object类方法;导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。wait()方法和notify()/notifyAll()方法在放弃对象监视器的时候的区别在于:wait()方法立即释放对象监视器,notify()/notifyAll()方法则会等待线程剩余代码执行完毕才会放弃对象监视器。
    • 线程yield:Thread类方法。暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线程。
    • 线程join:Thread.join()方法,当前线程中调用另一个线程的join()方法,则当前线程转入阻塞状态,直到另一个线程运行结束,当前线程再由阻塞转为就绪状态。
    • 线程唤醒notify,notifyAll:Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。 直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。

java中如何实现多线程

  1. 三种方式,并与Future、线程池结合使用

    • Thread父类
    • Runnable接口
    • Callable接口 (java 5新增) 有返回值
  2. 实现Runnable接口相比继承Thread类有如下优势:

    • 避免单继承带来的局限
    • 增强代码的健壮性,代码能够被多个程序共享,代码与数据时独立的
    • 合适多个相同程序代码的线程区处理同一资料的情况

线程安全与共享资源

允许被多个线程同时执行的代码称作线程安全的代码。线程安全的代码不包含竞态条件。当多个线程同时更新共享资源时会引发竞态条件。因此,了解Java线程执行时共享了什么资源很重要。

  • 局部变量:
    • 局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享
    • 基础类型的局部变量是线程安全的
    • 引用类型的局部变量:如果在某个方法中创建的对象不会逃逸出该方法,即该对象既不会被其他方法获得,也不会被非局部变量引用,那么认为它是线程安全的。
  • 成员对象
    • 对象成员存储在堆上
    • 如果两个线程同时更新同一个对象实例的同一个成员,那这个代码就不是线程安全的。
  • 线程控制逃逸规则:判断某些资源的访问是否线程安全
    • 如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。

线程同步的方法有什么?

    • Lock:可中断锁
      • ReenterantLock:可重入锁,
    • ReadWriteLock:一个用来获取读锁,一个用来获取写锁。也就是说,将对临界资源的读写操作分成两个锁来分配给线程,从而使得多个线程可以同时进行读操作。
      • ReentrantReadWriteLock:可重入锁,
  • synchronized:不是可中断锁

    • Java中的同步块用synchronized标记。同步块在Java中是同步在某个对象上。所有同步在一个对象上的同步块在同时只能被一个线程进入并执行操作。所有其他等待进入该同步块的线程将被阻塞,直到执行该同步块中的线程退出。
    • 实例方法:同步在拥有该方法的实例对象上。
    • 静态方法:同步在该方法所在的类对象上。
    • 实例方法中的同步块:在同步构造器中用括号括起来的对象叫做监视器对象。使用监视器对象同步,同步实例方法使用调用方法本身的实例this作为监视器对象。一次只有一个线程能够在同步于同一个监视器对象的Java方法内执行。如果一个在this,另一个不在this,那么两个方法可以被线程同时执行。
    • 静态方法中的同步块:同步在该方法所属的Class类对象上。等同于静态方法添加synchronized修饰,不可以同时被两个线程访问
  • 信号量

  • lock与synchronized的区别

    1. Lock是一个接口,是JDK层面的实现;而synchronized是Java中的关键字,是Java的内置特性,是JVM层面的实现;
    2. synchronized 在发生异常时,会自动释放线程占有的锁,因此不会导致死锁现象发生;而Lock在发生异常时,如果没有主动通过unLock()去释放锁,则很可能造成死锁现象,因此使用Lock时需要在finally块中释放锁;
    3. Lock 可以让等待锁的线程响应中断,而使用synchronized时,等待的线程会一直等待下去,不能够响应中断;
    4. 通过Lock可以知道有没有成功获取锁,而synchronized却无法办到;

锁的等级

  • 类锁
  • 对象锁
  • 方法锁

线程通信

线程通信的目标是使线程间能够互相发送信号。另一方面,线程通信使线程能够等待其他线程的信号。

方式有哪些?
  1. 共享对象
    • 即java的内存模型 JMM
  2. 忙等待
  3. wait() notify() notifyAll()
    • Java有一个内建的等待机制来允许线程在等待信号的时候变为非运行状态。java.lang.Object 类定义了三个方法,wait()、notify()和notifyAll()来实现这个等待机制。
    • 一个线程一旦调用了任意对象的wait()方法,就会变为非运行状态,直到另一个线程调用了同一个对象的notify()方法。为了调用wait()或者notify(),线程必须先获得那个对象的锁。也就是说,线程必须在同步块里调用wait()或者notify()
    • 当一个线程调用一个对象的notify()方法,正在等待该对象的所有线程中将有一个线程被唤醒并允许执行(校注:这个将被唤醒的线程是随机的,不可以指定唤醒哪个线程)。同时也提供了一个notifyAll()方法来唤醒正在等待一个给定对象的所有线程。
    • 如你所见,不管是等待线程还是唤醒线程都在同步块里调用wait()和notify()。这是强制性的!一个线程如果没有持有对象锁,将不能调用wait(),notify()或者notifyAll()。否则,会抛出IllegalMonitorStateException异常。
    • 一旦线程调用了wait()方法,它就释放了所持有的监视器对象上的锁。这将允许其他线程也可以调用wait()或者notify()。
    • 一旦一个线程被唤醒,不能立刻就退出wait()的方法调用,直到调用notify()的线程退出了它自己的同步块。换句话说:被唤醒的线程必须重新获得监视器对象的锁,才可以退出wait()的方法调用,因为wait方法调用运行在同步块里面。如果多个线程被notifyAll()唤醒,那么在同一时刻将只有一个线程可以退出wait()方法,因为每个线程在退出wait()前必须获得监视器对象的锁。
    • 为何这三个不是Thread类声明中的方法,而是Object类中声明的方法?
      • 其实这个问题很简单,由于每个对象都拥有monitor(即锁),所以让当前线程等待某个对象的锁,当然应该通过这个对象来操作了。而不是用当前线程来操作,因为当前线程可能会等待多个线程的锁,如果通过线程来操作,就非常复杂了。
  4. 丢失的信号
    • 针对notify()有一个问题:即通知信号的丢失,如果一个线程先于被通知线程调用wait()前调用了notify(),等待的线程将错过这个信号。这可能使等待线程永远在等待。
    • 为了避免信号丢失, 用一个变量来保存是否被通知过。在notify前,设置自己已经被通知过。在wait后,设置自己没有被通知过,需要等待通知。
  5. 假唤醒
    • 由于莫名其妙的原因,线程有可能在没有调用过notify()和notifyAll()的情况下醒来。这就是所谓的假唤醒(spurious wakeups)。无端端地醒过来了。然后也能够执行后续的操作。这可能导致你的应用程序出现严重问题。
    • 为了防止假唤醒,保存信号的成员变量将在一个while循环里接受检查,而不是在if表达式里。这样的一个while循环叫做自旋锁(校注:这种做法要慎重,目前的JVM实现自旋会消耗CPU,如果长时间不调用doNotify方法,doWait方法会一直自旋,CPU会消耗太大)。被唤醒的线程会自旋直到自旋锁(while循环)里的条件变为false
        MonitorObject myMonitorObject = new MonitorObject();
        boolean wasSignalled = false;
        public void doWait(){
            synchronized(myMonitorObject){
            while(!wasSignalled){
                try{
                myMonitorObject.wait();
                } catch(InterruptedException e){...}
            }
            //clear signal and continue running.
            wasSignalled = false;
            }
        }
        public void doNotify(){
            synchronized(myMonitorObject){
            wasSignalled = true;
            myMonitorObject.notify();
            }
        }
    
    
    • 留意wait()方法是在while循环里,而不在if表达式里。如果等待线程没有收到信号就唤醒,wasSignalled变量将变为false,while循环会再执行一次,促使醒来的线程回到等待状态。
  6. 多线程等待相同的信号
    • 如果你有多个线程在等待,被notifyAll()唤醒,但只有一个被允许继续执行,使用while循环也是个好方法。每次只有一个线程可以获得监视器对象锁,意味着只有一个线程可以退出wait()调用并清除wasSignalled标志(设为false)。
    • 一旦这个线程退出doWait()的同步块,其他线程退出wait()调用,并在while循环里检查wasSignalled变量值。但是,这个标志已经被第一个唤醒的线程清除了,所以其余醒来的线程将回到等待状态,直到下次信号到来。
  7. 不要对常量字符串或者全局对象调用wait()
    • 在空字符串作为锁的同步块(或者其他常量字符串)里调用wait()和notify()产生的问题是,JVM/编译器内部会把常量字符串转换成同一个对象。这意味着,即使你有2个不同的MyWaitNotify实例,它们都引用了相同的空字符串实例。同时也意味着存在这样的风险:在第一个MyWaitNotify实例上调用doWait()的线程会被在第二个MyWaitNotify实例上调用doNotify()的线程唤醒。
管程

管程 (英语:Monitors,也称为监视器) 是对多个工作线程实现互斥访问共享资源的对象或模块。这些共享资源一般是硬件设备或一群变量。管程实现了在一个时间点,最多只有一个线程在执行它的某个子程序。与那些通过修改数据结构实现互斥访问的并发程序设计相比,管程很大程度上简化了程序设计。

死锁
  1. 死锁是两个或更多线程阻塞着等待其它处于死锁状态的线程所持有的锁。死锁通常发生在多个线程同时但以不同的顺序请求同一组锁的时候。
  2. 避免死锁
    • 加锁顺序
      • 当多个线程需要相同的一些锁,但是按照不同的顺序加锁,死锁就很容易发生。如果能确保所有的线程都是按照相同的顺序获得锁,那么死锁就不会发生。
    • 加锁时限
      • 按照顺序加锁是一种有效的死锁预防机制。但是,这种方式需要你事先知道所有可能会用到的锁(译者注:并对这些锁做适当的排序),但总有些时候是无法预知的。此时就需要另一个种方法:加时
      • 在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。
      • 高并发下,这种方式效率不高
    • 死锁检测
      • 主要是针对那些不可能实现按序加锁并且锁超时也不可行的场景。
  3. java中的锁
    • 简单的锁
      • Lock.java 不可重入,因为存在自旋锁的原因
        • 解决:增加对当前线程的判断,如果同一个线程,可以进入;否则等待
      • Reentrant.java 可以重入
    • 锁的可重入性
      • Java中的synchronized同步块是可重入的。这意味着如果一个java线程进入了代码中的synchronized同步块,并因此获得了该同步块使用的同步对象对应的管程上的锁,那么这个线程可以进入由同一个管程对象所同步的另一个java代码块。
      • 如果一个线程已经拥有了一个管程对象上的锁,那么它就有权访问被这个管程对象同步的所有代码块。这就是可重入。
      • 当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果摸个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是调用。重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程所持有,当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1,如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。
    • 锁的公平性
      • synchronized块并不保证尝试进入它们的线程的顺序。因此可能出现线程饥饿现象。需要额外实现,保证公平性
    • 在finally语句中调用unlock()
饥饿与公平
  1. 饥饿:如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态被称之为“饥饿”。
    • 原因:
      • 高优先级线程吞噬所有的低优先级线程的CPU时间。
      • 线程被永久堵塞在一个等待进入同步块的状态。
      • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法)。
    • 解决方式
      • 使用锁,而不是同步块
      • 公平锁
      • 注意性能方面
      • 参考资料
嵌套管程死锁

死锁中,二个线程都在等待对方释放锁。

嵌套管程锁死中,线程1持有锁A,同时等待从线程2发来的信号,线程2需要锁A来发信号给线程1。

读写锁

Java5在java.util.concurrent包中已经包含了读写锁。

信号量

Semaphore(信号量) 是一个线程同步结构,用于在线程间传递信号,以避免出现信号丢失(译者注:下文会具体介绍),或者像锁一样用于保护一个关键区域。自从5.0开始,jdk在java.util.concurrent包里提供了 Semaphore 的官方实现,因此大家不需要自己去实现Semaphore。但是还是很有必要去熟悉如何使用Semaphore及其背后的原理

  1. Semaphore的实现
  2. 使用Semaphore
  3. 可计数的Semaphore
  4. 有上限的Semaphore
  5. 把Semaphore当锁来使用

同步方法和同步块,哪个是更好的选择

  1. 同步块,这意味着同步块之外的代码是异步执行的,这比同步整个方法更提升代码的效率。请知道一条原则:同步的范围越小越好。

  2. 但是借着这一条,我额外提一点,虽说同步的范围越少越好,但是在Java虚拟机中还是存在着一种叫做锁粗化的优化方法,这种方法就是把同步范围变大。这是有用的,比方说StringBuffer,它是一个线程安全的类,自然最常用的append()方法是一个同步方法,我们写代码的时候会反复append字符串,这意味着要进行反复的加锁->解锁,这对性能不利,因为这意味着Java虚拟机在这条线程上要反复地在内核态和用户态之间进行切换,因此Java虚拟机会将多次append方法调用的代码进行一个锁粗化的操作,将多次的append的操作扩展到append方法的头尾,变成一个大的同步块,这样就减少了加锁–>解锁的次数,有效地提升了代码执行的效率。

生产者消费者模式的几种实现,

  • 阻塞队列实现,BlockingQueue
  • synchronized关键字实现,
  • lock实现,
  • reentrantLock等
手写出生产者消费者模式
  • 阻塞队列实现,BlockingQueue
  • synchronized关键字实现,

什么是乐观锁和悲观锁

  1. 乐观锁:就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总是会发生,因此它不需要持有锁,将比较-替换CAS这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。

  2. 悲观锁:还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像synchronized,不管三七二十一,直接上了锁就操作资源了。

wait() 与 sleep() 的区别

  • sleep()方法是线程类(Thread)的静态方法,导致此线程暂停执行指定时间,将执行机会给其他线程,但是监控状态依然保持,到时后会自动恢复(线程回到就绪(ready)状态),因为调用sleep 不会释放对象锁。
  • wait()是Object 类的方法,对此对象调用wait()方法导致本线程放弃对象锁(线程暂停执行),进入等待此对象的等待锁定池,只有针对此对象发出notify 方法(或notifyAll)后本线程才进入对象锁定池准备获得对象锁进入就绪状态。

yield()和join()方法的使用

  • join方法用线程实例对象调用,如果在一个线程A中调用另一个线程B的join方法,线程A将会等待线程B执行完毕后再执行。
  • yield()可以直接用Thread类调用,yield()让出CPU执行权给同等级的线程,如果没有相同级别的线程在等待CPU的执行权,则该线程继续执行。

线程中断

  • 使用interrupt()中断线程:当一个线程运行时,另一个线程可以调用对应的Thread对象的interrupt()方法来中断它,该方法只是在目标线程中设置一个标志,表示它已经被中断,并立即返回。这里需要注意的是,如果只是单纯的调用interrupt()方法,线程并没有实际被中断,会继续往下执行
  • 待决中断:如果线程在调用sleep()方法前就被中断,那么该中断称为待决中断,它会在刚调用sleep()方法时,立即抛出InterruptedException异常。
    public class PendingInterrupt extends Object{

        public static void main(String[] args) {
            //如果输入了参数,则在main线程中中断当前线程(即main线程)
            if(args.length > 0){
                Thread.currentThread().interrupt();
            }
            //获取当前时间
            long startTime = System.currentTimeMillis();
            try {
                Thread.sleep(2000);
                System.out.println("was NOT interrupted");
            } catch (InterruptedException e) {
                System.out.println("was interrupted");
            }
            //计算中间代码执行的时间
            System.out.println("elapsedTime=" + (System.currentTimeMillis() - startTime));
        }
    }

这种模式下,main线程中断它自身。除了将中断标志(它是Thread的内部标志)设置为true外,没有其他任何影响。线程被中断了,但main线程仍然运行,main线程继续监视实时时钟,并进入try块,一旦调用sleep()方法,它就会注意到待决中断的存在,并抛出InterruptException。于是执行跳转到catch块,并打印出线程被中断的信息。最后,计算并打印出时间差。

  • 使用isInterrupted()方法判断中断状态
    可以在Thread实例对象上调用isInterrupted()方法来检查任何线程的中断状态。这里需要注意:线程一旦被中断,isInterrupted()方法便会返回true,而一旦sleep()方法抛出异常,它将清空中断标志,此时isInterrupted()方法将返回false。
  • 使用Thread.interrupted()方法判断中断状态
    可以使用Thread.interrupted()方法来检查当前线程的中断状态(并隐式重置为false)。又由于它是静态方法,因此不能在特定的线程上使用,而只能报告调用它的线程的中断状态,如果线程被中断,而且中断状态尚不清楚,那么,这个方法返回true。与isInterrupted()不同,它将自动重置中断状态为false,第二次调用Thread.interrupted()方法,总是返回false,除非中断了线程。

守护线程与阻塞线程

  1. 用户线程:即运行在前台的线程

  2. 守护线程:是运行在后台的线程

    • 守护线程作用是为其他前台线程的运行提供便利服务,而且仅在普通、非守护线程仍然运行时才需要,比如垃圾回收线程就是一个守护线程。当VM检测仅剩一个守护线程,而用户线程都已经退出运行时,VM就会退出,因为如果没有了守护者,也就没有继续运行程序的必要了。如果有非守护线程仍然活着,VM就不会退出。
    • 守护线程并非只有虚拟机内部提供,用户在编写程序时也可以自己设置守护线程。用户可以用Thread的setDaemon(true)方法设置当前线程为守护线程。
      • setDaemon(true)必须在调用线程的start()方法之前设置,否则会跑出IllegalThreadStateException异常。
      • 在守护线程中产生的新线程也是守护线程
      • 不要认为所有的应用都可以分配给守护线程来进行服务,比如读写操作或者计算逻辑。
  3. 线程阻塞:

    • (可中断)当线程执行Thread.sleep()时,它一直阻塞到指定的毫秒时间之后,或者阻塞被另一个线程打断
    • (可中断)当线程碰到一条wait()语句时,它会一直阻塞到接到通知(notify())、被中断或经过了指定毫秒 时间为止(若指定了超时值的话)
    • 线程阻塞与不同的I/O的方式有多种。常见的一种方式是InputStream的read()方法,该方法一直阻塞到从流中读取一个字节的数据为止,它可以无限阻塞,因此不能指定超时时间
    • 线程也可以阻塞等待获取某个对象锁的排它性访问权限(即等待获得synchronized语句必须的锁时阻塞)

多线程环境中安全使用集合API

  • public static Collection synchronizedCollention(Collection c)
  • public static List synchronizedList(list l)
  • public static Map synchronizedMap(Map m)
  • public static Set synchronizedSet(Set s)
  • public static SortedMap synchronizedSortedMap(SortedMap sm)
  • public static SortedSet synchronizedSortedSet(SortedSet ss)

并发编程中实现内存可见的两种方法比较:加锁synchronized和volatile变量

  1. volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。

  2. 内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。

  3. 在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。

  4. 加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

  5. 当且仅当满足以下所有条件时,才应该使用volatile变量:

    • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    • 该变量没有包含在具有其他变量的不变式中。

总结:在需要同步的时候,第一选择应该是synchronized关键字,这是最安全的方式,尝试其他任何方式都是有风险的。尤其在、jdK1.5之后,对synchronized同步机制做了很多优化,如:自适应的自旋锁、锁粗化、锁消除、轻量级锁等,使得它的性能明显有了很大的提升。

写出3条你遵循的多线程最佳实践

  1. 给你的线程起个有意义的名字,这样可以方便找bug或追踪。
  2. 根据具体的使用场景,选择锁定和缩小同步的范围(或小,或大(考虑StringBuffer append使用场景 就是用到锁粗化))
  3. 多用同步类少用wait 和 notify。CountDownLatch, Semaphore, CyclicBarrier 和 Exchanger 这些同步类简化了编码操作,而用wait和notify很难实现对复杂控制流的控制。
  4. 多用并发集合少用同步集合
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值