【Java】多线程
基本概念
进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。 在当代面向线程设计的计算机结构中,进程是线程的容器。程序是指令、数据及其组织形式的描述,进程是程序的实体。是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。程序是指令、数据及其组织形式的描述,进程是程序的实体。
线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。
- 进程:指在系统中正在运行的一个应用程序;程序一旦运行就是一个进程;进程——资源分配的最小单位
- 线程:系统分配处理器时间资源的基本单元,或者说进程之内独立执行的一个单元执行流;线程——程序执行的最小单位
举例解释进程与线程:
- IDEA 这个应用程序启动后就是一个单独的进程
- IDEA 里的各种语法提示、错误报警等功能都是一个个线程并发运行
并发和并行的区别:
- 并行:同一个时间点多个线程一起执行
- 并发:同一个时间段多个线程交替执行
创建多线程
创建多线程有四种方式:
- 继承
Thread
类 - 实现
Runnable
接口 - 实现
Callable
接口 - 使用线程池
方式一:继承 Thread 类
- 创建一个继承于
Thread
类的子类 - 重写
Thread
类的run()
--> 将此线程执行的操作声明在run()
中 - 创建
Thread
类的子类的对象 - 通过此对象调用
start()
1 | // 1. 创建一个继承于Thread类的子类 |
start()
方法底层是调用 native
修饰的 start0()
方法,其调用的并非JVM中的Java程序,而是本地的C语言程序执行线程任务,因此调用 t1.start()
方法时该线程并不一定立即执行,其执行时机由CPU决定。
方式二:实现 Runnable 接口
- 创建一个实现了
Runnable
接口的类 - 实现类去实现
Runnable
中的抽象方法:run()
- 创建实现类的对象
- 将此对象作为参数传递到
Thread
类的构造器中,创建Thread
类的对象 - 通过Thread类的对象调用
start()
1 | // 1. 创建一个实现了Runnable接口的类 |
方式三:实现 Callable 接口
和Runnable
相比,Callable
功能更强大:
- 相比
run()
方法,call()
方法可以有返回值(借助于FutureTask
类) - 方法可以抛出异常
- 支持泛型的返回值
- 需要借助
FutureTask
类,比如获取返回结果
其中:
- 第一次调用
FutureTask
的get()
方法时,线程会一直阻塞直到任务结束然后获取结果; - 第二次调用
FutureTask
的get()
方法时,将不会再次运行线程,而是直接返回结果。
1 | package multiThread; |
方式四:使用线程池
经常创建和销毁、使用量特别大的资源,比如并发情况下线程对性能影响很大。思路: 提前创建好多个线程,放入池中使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。好处:
- 提高响应速度 (减少了创建新线程的时间)
- 降低资源消耗 (重复利用线程池中,不需要每次都创建)
- 便于线程管理
corePoolSize
:核心池的大小maximumPoolSize
:最大线程数keepAliveTime
:线程没有任务时最多保持长间后会 终止
1 | import java.util.concurrent.ExecutorService; |
Thread 和 Runnable 的关系
Thread类实现了Runnable接口,该方法本质上使用了静态代理的设计模式:Thread类实现了Runnable接口中的run()
方法,调用Thread的run()
方法时,会调用在构造器中传入的自定义类的run()
方法,因此是一种静态代理的设计模式:
1 | class Thread implements Runnable { |
源码
构造方法:
静态代理模式:
在调用 Thread 对象的 start()
方法时,将会调用 native
修饰的 start0()
方法,其调用的并非JVM中的Java程序,而是本地的C语言程序执行线程任务,因此调用 start()
方法时该线程并不一定立即执行,其执行时机由CPU决定。
这个新开辟的线程中将调用Thread的run()
方法(本质还是调用了Runnable的run()
方法)
如果在当前线程中直接调用
run()
方法,则不会开辟新线程,而是直接在当前线程中执行run()
方法。
总结
1 | class MyRunnable implements Runnable{} // 相当于被代理类 |
开发中优先选择:实现Runnable
接口的方式。原因:
- 实现的方式没有类的单继承性的局限性
- 实现的方式更适合来处理多个线程有共享数据的情况。
相同点:两种方式都需要重写run()
,将线程要执行的逻辑声明在run()
中。
设计 Thread 的目的可能是,用户只需要实现 Runnable 接口里的 run() 方法,只需要关注自己的业务,不需要在意和 JVM 本地方法相关的操作。
线程运行原理
栈与栈帧
Java Virtual Machine Stacks (Java 虚拟机栈)
我们都知道 JVM 中由堆、栈、方法区所组成,其中栈内存是给谁用的呢?其实就是线程,每个线程启动后,虚拟机就会为其分配一块栈内存。 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。
线程上下文切换(Thread Context Switch)
因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码。
- 线程的 cpu 时间片用完
- 垃圾回收
- 有更高优先级的线程需要运行
- 线程自己调用了 sleep、yield、wait、join、park、synchronized、lock 等方法
当 Context Switch 发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的。状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等。Context Switch 频繁发生会影响性能。
一个普通程序启动后会产生哪些线程
- main
- Attach Listener
- Signal Dispatcher
- Finalizer
- Reference Handler
Thread 类中常用方法
Thread
中的常用方法:
start()
:启动当前线程;调用当前线程的run()
run()
:通常需要重写Thread类中的此方法,将创建的线程要执行的操作声明在此方法中yield()
:自己主动释放当前cpu的执行权,释放后所有线程重新竞争cpu执行权join()
:在线程a中调用线程b的join()
,此时线程a就进入阻塞状态等待线程b执行完毕。直到线程b完全执行完以后,线程a才结束阻塞状态。sleep(long millitime)
:让当前线程“睡眠”指定的millitime毫秒。在指定的millitime毫秒时间内,当前线程是阻塞状态。会释放cpu的执行权(释放时间片),但不会释放持有的锁(Object.wait()
方法会释放锁)stop()
:已过时。当执行此方法时,强制结束当前线程。不推荐使用(会破坏同步代码块,导致锁失效与死锁。无法再恢复到停止时刻状态,而sleep()
等都可以恢复)。若想停止某个线程推荐使用interrupt()
方法interrupt()
:打断某个线程。如果被打断线程正在 sleep/wait/join,会导致被打断的线程抛出InterruptedException
,并清除打断标记;如果打断正在运行的线程,则会设置打断标记;park 的线程被打断,也会设置打断标记isAlive()
:判断当前线程是否存活currentThread()
:静态方法,返回执行当前代码的线程getName()
:获取当前线程的名字setName()
:设置当前线程的名字
sleep()
- 调用 sleep 会让当前线程从 Running 状态进入 Timed Waiting 状态(阻塞)
- 其它线程可以使用
interrupt()
方法打断正在睡眠的线程,被打断的线程这时就会抛出InterruptedException
异常【注意:这里打断的是正在休眠的线程,而不是其它状态的线程】 - 睡眠结束后的线程未必会立刻得到执行(需要分配到cpu时间片)
善用 sleep()
方法解决 CPU 运行 100% 问题:在程序中的无限循环代码块 while(true)
中添加 sleep(xxx)
方法可以有效地防止 CPU 运行 100%。例如,Nacos 的服务端代码中就在服务注册功能中使用到了该技巧。
yield()
yield:屈服
- 调用
yield()
方法会让当前线程从 Running 进入 Runnable 就绪状态,然后所有线程重新竞争cpu执行权(自己依然可能再次抢到 cpu 执行权) - 具体的实现依赖于操作系统的任务调度器,可能自己刚释放cpu就再次抢到控制权
注意:yield()
方法和线程优先级都不是决定性因素,最终是哪个线程抢到资源还是看操作系统的线程任务调度器。
yield()
方法使得线程从运行状态变为就绪状态(可以立即抢cpu控制权),而sleep()
方法使得线程从运行状态变为阻塞状态
当线程进入 IO 阻塞状态时,操作系统也会收回其 cpu 执行权。
join()
join()
方法是保护性暂停模式的一种实现。常使用join()
方法实现线程间的同步协作。例如主线程需要等待其余线程都执行完毕才能继续执行
在线程a中调用线程b的join()
,此时线程a就进入阻塞状态等待线程b执行完毕。直到线程b完全执行完以后,线程a才结束阻塞状态。
join()
方法属于比较底层的 API。一旦使用 join()
,就必须等待指定线程完全执行完毕才可以继续执行。如果想实现b达到某个条件后a就可以继续执行,则可以使用更高级的 API 实现线程间的同步协作,例如 CountDownLatch
。
1 | private static void test1() throws InterruptedException { |
join()
的原理:保护性暂停模式。其原理的简化版本就是使用 wait()/notify()
机制。调用 join()
的线程一直轮询检查线程 alive 状态:
1 | // 等价于下面的代码 |
interrupt()
调用 sleep/wait 方法必须要添加 try/catch 块的原因就是其阻塞期间可能被其他线程打断
interrupt()
方法可以打断任意线程,无论是正在运行还是阻塞状态,都可以打断。
- 阻塞中的线程被打断时会抛出异常,并设置为 true,但是一旦该异常被 catch 捕获,就会清除该标志:自动将打断标志位设置 false(因为抛出异常了就不需要再设置打断标志位了)
- 正在运行的线程不会抛出异常,而是将打断标志位设置为 true。这样该线程就可以自己决定是否需要中断自己
情景一:打断阻塞中的线程
调用 sleep/wait/join 方法的线程会进入阻塞状态,此时可以在其他线程调用 interrupt()
方法打断该线程。必须使用 try/catch 块捕获可能出现的异常:
1 | public static void main(String[] args) throws InterruptedException { |
情景二:打断正常运行的线程
打断正在运行的线程不会抛出异常,而是将打断标志位设置为 true。这样该线程就可以自己决定是否需要中断自己:
1 | public static void main(String[] args) throws InterruptedException { |
sleep/yield/join/wait 对比
方法来源:
sleep/join/yield
是Thread
类中的方法wait/notify
是Object
中的方法
是否释放 cpu 时间片和锁:
sleep()
:释放 cpu 时间片、不释放锁、进入阻塞状态yield()
:释放 cpu 时间片、不释放锁、进入就绪状态join()
:释放 cpu 时间片、释放锁、进入阻塞状态wait()
:释放 cpu 时间片、释放锁、进入阻塞状态
线程优先级
线程的优先级:
MAX_PRIORITY
:10MIN_PRIORITY
:1NORM_PRIORITY
:5 --> 默认优先级
如何获取和设置当前线程的优先级:
getPriority()
:获取线程的优先级setPriority(int p)
:设置线程的优先级
说明:高优先级的线程要抢占低优先级线程cpu的执行权。但是只是从概率上讲,高优先级的线程高概率的情况下被执行。并不意味着只有当高优先级的线程执行完以后,低优先级的线程才执行。
如果 cpu 比较忙,那么优先级高的线程会获得更多的时间片,但 cpu 空闲时,优先级几乎没作用
当调用
notify()
唤醒线程时,会优先唤醒优先级高的线程。如果优先级一样就随机唤醒。
线程状态
线程的生命周期
只有
yield()
方法可以主动进入就绪状态,其余都是进入阻塞状态。
线程状态之五种状态
线程的五种状态主要是从操作系统的层面进行划分的:
线程的五种状态:
- 初始状态:仅仅是在语言层面上创建了线程对象,即
Thead thread = new Thead()
,还未与操作系统的线程关联 - 可运行状态:也称就绪状态,指该线程已经被创建,与操作系统相关联,已经拥有所需要的资源(主要是内存),等待 CPU 给它分配时间片就可运行。就绪状态的线程才能够争抢 CPU 时间片,争抢失败的继续在就绪状态等待稍后再次争抢 CPU
- 运行状态:指线程获取了 CPU 时间片,正在运行。当 CPU 时间片用完,线程会转换至【可运行状态】,等待 CPU 再次分配时间片,会导致我们前面讲到的上下文切换
- 阻塞状态:
- 如果调用了阻塞 API,如 BIO 读写文件,那么线程实际上不会用到 CPU,不会分配 CPU 时间片,会导致上下文切换,进入【阻塞状态】
- 等待 BIO 操作完毕,会由操作系统唤醒阻塞的线程,转换至【可运行状态】
- 与【可运行状态】的区别是,只要操作系统一直不唤醒线程,调度器就一直不会考虑调度它们,CPU 就一直不会分配时间片
- 调用
Thread.sleep()
、wait()
、LockSupport.park()
、lock()
等方法后线程都会进入阻塞状态。直到该线程“睡眠结束、被唤醒、其他线程释放锁后”才会从阻塞状态结束进入就绪状态,开始争抢 CPU 时间片,如果争抢成功就进入运行状态,否则仍然进入就绪状态等待下一次 CPU 时间片的争抢
- 终止状态:表示线程已经执行完毕,生命周期已经结束,不会再转换为其它状态、、
就绪状态和阻塞状态的区别:
- 就绪状态的线程随时可以进行争抢 CPU 时间片
- 阻塞状态的线程必须等待阻塞结束才可以进入就绪状态。进入阻塞状态的原因:
- IO 阻塞
- 调用
Thread.sleep()
、wait()
、LockSupport.park()
等方法令自身进入等待状态 - 其他线程先占用了锁,导致自己只能进入阻塞状态等待其释放锁
- 阻塞状态的线程只有等到“睡眠结束、被唤醒、其他线程释放锁后”才会从阻塞状态结束进入就绪状态
线程状态之六种状态
线程的六种状态是从 Java API 层面来描述的,这样划分是为了更好区分不同 API 调用后的具体线程状态(例如是等待还是阻塞还是超时等待):
NEW
跟五种状态里的初始状态是一个意思RUNNABLE
是当调用了start()
方法之后的状态。表示该线程已经在 JVM 中执行,当然由于执行需要计算资源,它可能是正在运行,也可能还在等待系统分配给它 CPU 片段,在就绪队列里面排队(并非被锁阻塞)。注意,Java API 层面的RUNNABLE
状态涵盖了操作系统层面的**【可运行状态】、【运行状态】和【IO 导致的阻塞状态】**(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为是可运行)。注意只包含 IO 导致的阻塞,而不包含wait/sleep
等造成的阻塞TERMINATED
代表当线程代码运行结束BLOCKED
,WAITING
,TIMED_WAITING
都是 Java API 层面对【阻塞状态】的细分。以synchronized
锁情况下为例:BLOCKED
:进入Monitor
的阻塞队列EntryList
(竞争 cpu 失败的线程都会进入BLOCKED
状态,等待下一次争抢 cpu 时间片)WAITING
:进入Monitor
的等待队列WaitSet
(被唤醒后如果竞争锁失败将进入BLOCKED
状态)TIMED_WAITING
:进入 Monitor 的等待队列WaitSet
,带超时时间,超出时间后自动被唤醒
注意:就绪状态是在
RUNNABLE
状态内的,代表有资格竞争 CPU 时间片,而没有被锁阻塞或主动sleep
重要:BLOCKED
和 WAITING/TIME_WAITING
的区别:
BLOCKED
是因为线程竞争锁失败导致的阻塞WAITING/TIME_WAITING
是因为线程调用Thread.sleep()
、wait()
、LockSupport.park()
等方法导致的阻塞
一个是因为竞争锁失败,另一个则是主动调用阻塞等待方法。
三者与 RUNNABLE
状态的具体转换:
RUNNABLE <–> BLOCKED
竞争 CPU 失败的是
RUNNABLE
,而不是BLOCKED
。
竞争锁失败的线程都会进入 BLOCKED
状态:
- t 线程用
synchronized(obj)
获取了对象锁时,如果竞争失败,从RUNNABLE --> BLOCKED
- 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有
BLOCKED
的线程重新竞争,如果其中 t 线程竞争
成功,从BLOCKED --> RUNNABLE
,其它失败的线程仍然BLOCKED
线程的竞争结果决定了 BLOCKED
状态是否能转换为 RUNNABLE
状态。
- 如果竞争成功,就可以转为
RUNNABLE
状态 - 如果竞争失败,仍然处于
BLOCKED
状态
竞争锁失败的线程都会进入 BLOCKED
状态,阻塞等待直到持有锁的线程释放锁,然后再一起竞争锁,失败仍然 BLOCKED
,成功的进入 RUNNABLE
。
注意:竞争 CPU 失败和竞争锁失败导致的结果是不同的:
- 竞争 CPU 失败的线程是
RUNNABLE
状态,其并没有被阻塞,随时可能竞争下一次的 CPU - 竞争锁失败的线程是
BLOCKED
状态,因为其必须等待锁被释放了才能尝试获取锁,如果获取锁失败了仍然是BLOCKED
,获取锁成功了才是RUNNABLE
RUNNABLE <–> WAITING
情况一:线程用 synchronized(obj)
获取了对象锁后:
- 调用
obj.wait()
方法时,t 线程从RUNNABLE --> WAITING
(进入Monitor
的等待队列WaitSet
,被唤醒如果竞争失败就将进入BLOCKED
状态) - 调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时:- 竞争锁成功,t 线程从
WAITING --> RUNNABLE
- 竞争锁失败,t 线程从
WAITING --> BLOCKED
- 竞争锁成功,t 线程从
情况二:LockSupport
精确唤醒:
- 当前线程调用
LockSupport.park()
方法会让当前线程从RUNNABLE --> WAITING
- 调用
LockSupport.unpark(目标线程)
或调用了线程的interrupt()
,会让目标线程从WAITING --> RUNNABLE
(因为是精确唤醒,所以不可能竞争失败,也就不可能进入BLOCKED
状态)
情况三:join()
方法
- 当前线程调用
t.join()
方法时,当前线程从RUNNABLE --> WAITING
。注意是当前线程在 t 线程对象的监视器上等待 - t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从WAITING --> RUNNABLE
RUNNABLE <–> TIMED_WAITING
TIMED_WAITING 状态都需要设置超时时间
情况一:t 线程用 synchronized(obj)
获取了对象锁后:
- 调用
obj.wait(long n)
方法时,t 线程从RUNNABLE --> TIMED_WAITING
- t 线程等待时间超过了 n 毫秒,或调用
obj.notify()
,obj.notifyAll()
,t.interrupt()
时:- 竞争锁成功,t 线程从
TIMED_WAITING --> RUNNABLE
- 竞争锁失败,t 线程从
TIMED_WAITING --> BLOCKED
- 竞争锁成功,t 线程从
情况二:join()
:
- 当前线程调用
t.join(long n)
方法时,当前线程从RUNNABLE --> TIMED_WAITING
注意是当前线程在t 线程对象的监视器上等待 - 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的
interrupt()
时,当前线程从 TIMED_WAITING --> RUNNABLE
情况三:sleep(long n)
- 当前线程调用
Thread.sleep(long n)
,当前线程从RUNNABLE --> TIMED_WAITING
- 当前线程等待时间超过了 n 毫秒或调用了线程的
interrupt()
,当前线程从TIMED_WAITING --> RUNNABLE
情况四:LockSupport
- 当前线程调用
LockSupport.parkNanos(long nanos)
或LockSupport.parkUntil(long millis)
时,当前线程从RUNNABLE --> TIMED_WAITING
- 调用
LockSupport.unpark(目标线程)
或调用了线程 的interrupt()
,或是等待超时,会让目标线程从TIMED_WAITING--> RUNNABLE
解决线程安全问题
方式一:synchronized 同步代码块
1 | synchronized(同步监视器){ |
操作同步代码时,只能有一个线程参与,其他线程等待。相当于是一个单线程的过程,效率低(局限性)。说明:
- 操作共享数据的代码,即为需要被同步的代码。不能包含代码多了,也不能包含代码少了。
- 共享数据:多个线程共同操作的变量。
- 同步监视器,俗称:锁。任何一个类的对象,都可以充当锁。
要求:多个线程必须要共用同一把锁(同步监视器):
- 在实现
Runnable
接口创建多线程的方式中,我们可以考虑使用this
充当同步监视器。因为Runnable
类对象被多个Thread
类对象所共享,所以多个线程共用同一把锁。 - 在继承
Thread
类创建多线程的方式中,因为多个线程类对象本身不能共享数据,因此需要设置一个static
修饰的对象作为锁,或使用反射方式xxx.class
作为锁。
实现Runnable接口例子:
1 | class Window1 implements Runnable{ |
若使用继承Thread
类的方式,需要在类中设置一个static
对象作为锁,或使用反射方式Window1.class
。
方式二:synchronized 同步方法
同步方法仍然涉及到同步监视器,只是不需要显示声明:
- 非静态的同步方法(适用于实现
Runnable
接口),同步监视器是:this
- 静态的同步方法(适用于继承
Thread
类),同步监视器是:当前类本身
非静态的同步方法(适用于实现Runnable
接口):
1 | class Window2 implements Runnable { |
静态的同步方法(适用于继承Thread
类):
1 | class Window3 extends Thread { |
方式三:Lock 锁
解决线程安全问题的方式三:Lock锁 — JDK5.0新增
synchronized
与Lock
的区别:
- 原始构成:
sync
是JVM层面的,底层通过monitorenter
和monitorexit
来实现的。Lock
是JDK API层面的。(sync
一个enter
会有两个exit
,一个是正常退出,一个是异常退出) - 使用方法:
sync
不需要手动释放锁(若发生异常也会自动释放),而Lock
需要手动释放unlock()
(通常写在finally代码块中防止发生异常无法释放同步监视器) - 是否可中断:
sync
不可中断,除非抛出异常或者正常运行完成。Lock
是可中断的,通过调用interrupt()
方法。 - 是否为公平锁:
sync
只能是非公平锁,而Lock
既能是公平锁,又能是非公平锁。 - 绑定多个条件:
sync
不能,只能随机唤醒。而Lock
可以通过Condition
来绑定多个条件,精确唤醒。
在高并发场景下,Lock
的性能远优于 synchronized
。
优先使用顺序:Lock
——> 同步代码块(已经进入了方法体,分配了相应资源)——> 同步方法(在方法体之外)
1 | class Window implements Runnable{ |
死锁的理解:不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁。出现死锁后,不会出现异常,不会出现提示,只是所有的线程都处于阻塞状态,无法继续。
synchronized 多种使用情况
synchronized
实现同步的基础:Java中的每一个对象都可以作为锁。具体表现为以下3种形式:
- 对于普通同步方法,锁是当前实例对象。
- 对于静态同步方法,锁是当前类的
Class
类模板。 - 对于同步方法块,锁是
synchonized
括号里配置的对象
情况一:普通同步方法 + 调用同一个对象
一个对象中的各个普通同步方法都是互斥的,一个普通同步方法上的 synchronized
锁的是当前类的实例对象而不仅仅是锁当前方法,同一时间只能有一个线程访问该类实例对象里的某一个普通同步方法,而不能有多个线程同时访问同一个对象的多个普通同步方法(但是可以同时访问他的普通非同步方法)。注意此时描述的是多个线程调用同一个对象里的多个普通同步方法。
举例:手机资源类中有很多不同功能的普通同步资源(普通同步方法),且该手机只有一部(一个实例对象),那么一旦某人(某线程)抢到了该手机(实例对象),那么不管他用的是哪个普通同步资源(哪个普通同步方法),其他人(其他线程)都无法再使用该手机(实例对象)里的任何普通同步资源(普通同步方法),则其他人(其他线程)可以同时听这部手机里的歌(普通非同步资源)
结论:普通同步方法上的 synchronized
锁的是当前实例对象,而不仅仅是当前方法,但要注意的是,普通非同步方法并不会被锁,其仍然能正常调用。
情况二:普通同步方法 + 调用不同的对象
对比情况一,如果多个线程调用的是不同对象的普通同步方法,则并不会互斥,因为普通同步方法上的锁只所当前实例对象,不同的实例对象上的锁不同,当然不会互斥。
情况三:普通同步方法 + 普通非同步方法
当调用的是普通方法(没有加synchronized
的方法)时,并不受锁的影响。
情况四:静态同步方法 + 调用不同对象
静态同步方法锁的是整个类模板,该类的所有实例对象都不能同时访问静态同步方法。但是同一个对象的静态同步方法和普通同步方法不互斥,两个线程可以同时访问静态同步方法和普通同步方法。因为两个方法锁一个锁当前类模板,一个锁当前对象,所以并不互斥。
情况五:静态同步方法 + 普通同步方法
同一个对象的静态同步方法和普通同步方法不互斥,两个线程可以同时访问静态同步方法和普通同步方法。因为两个方法锁一个锁当前类模板,一个锁当前对象,所以并不互斥。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。也就是说如果一个实例对象的非静态同步方法获取锁后,该实例对象的其他非静态同步方法必须等待获取锁的方法释放锁后才能获取锁,可是别的实例对象的非静态同步方法因为跟该实例对象的非静态同步方法用的是不同的锁,所以毋须等待该实例对象,已获取锁的非静态同步方法释放锁就可以获取他们自己的锁。所有的静态同步方法用的也是同一把锁——类对象本身,这两把锁是两个不同的对象,所以静态同步方法与非静态同步方法之间是不会有竞态条件的。
但是一旦一个静态同步方法获取锁后,其他的静态同步方法都必须等待该方法释放锁后才能获取锁,而不管是同一个实例对象的静态同步方法之间,还是不同的实例对象的静态同步方法之间,只要它们是同一个类的实例对象,都会互斥
死锁
两个或者两个以上线程在执行过程中,因为争夺资源而造成一种互相等待的现象。如果没有外力干涉,他们无法在执行下去。
产生死锁的原因:一个线程需要同时获取多把锁,多个这样的线程同时竞争这些锁就可能死锁。
- 系统资源不足
- 进程运行推进顺序不合适
- 资源分配不当
发生死锁的四个条件:
- 互斥条件,线程使用的资源至少有一个不能共享的。
- 至少有一个线程必须持有一个资源且正在等待获取一个当前被别的线程持有的资源。
- 资源不能被抢占。
- 循环等待。
死锁案例:
1 | public class DeadLock { |
验证是否是死锁
- jps:类似于linux中的
ps -ef
查看进程号 - jstack:自带的堆栈跟踪工具
- jvisualvm:Java虚拟机图形化检测器,可以查看死锁
通过用idea自带的命令行输入 jps -l
,查看其编译代码的进程号后使用 jstack 进程号
查看进程堆栈信息。死锁验证截图:
避免死锁的方案:可以使用 JUC 下的 Lock
锁,使用 tryLock()
方法尝试获取锁,若获取不到就不再加锁,从而可以避免死锁问题。可以使用该原理解决哲学家进餐问题(每人都需要左右两边都有叉子和勺子才可以进餐,使用tryLock()
就可以选择放弃从而让其他人进餐。
线程间的通信
本章介绍线程间进行通信的方法。所谓线程间通信,其实就是通过睡眠与唤醒机制,使得多个线程能相互通知(唤醒),整体按照一定的先后顺序交替执行,从而达到通信的效果。
共有三种方式可以实现睡眠与唤醒机制:
- 方式1:使用
Object
中的wait()
方法让线程等待,使用Object
中的notify()
方法唤醒线程 - 方式2:使用JUC包中
Condition
的await()
方法让线程等待,使用signal()
方法唤醒线程 - 方式3:使用
LockSupport
类的park()
阻塞当前线程,使用unpark(xxx)
唤醒指定被阻塞的线程
其他线程间通信手段需要用到更加高级的 API,例如 CountDownLatch、CyclicBarrier 等
wait()、notify()、notifyAll()
Object
类中包含三个方法:
wait()
:一旦执行此方法,当前线程就进入睡眠状态WAITING
,并释放锁,被其他线程唤醒后不会立即执行,而是阻塞等待其他线程释放锁后才会再次竞争锁,竞争成功后再从刚才睡眠的地方继续执行notify()
:一旦执行此方法,就会随机唤醒被wait的一个线程,如果有多个线程被wait,就唤醒优先级高的线程(如果优先级一样就随机唤醒),并且本线程继续向下执行(不会阻塞也不会释放锁)。可能造成虚假唤醒notifyAll()
:一旦执行此方法,就会唤醒所有被wait的线程,并且本线程继续向下执行(不会阻塞也不会释放锁)。可能造成虚假唤醒
注意:wait()
,notify()
,notifyAll()
这三个方法:
- 都是定义在
java.lang.Object
类中,所有 Java 类都拥有该方法 - 必须使用在
synchronized
修饰的同步代码块或同步方法中 - 三者的调用者必须是同步代码块或同步方法中的同步监视器(即
synchronized(obj)
锁住的obj
),否则会出现IllegalMonitorStateException
异常。即必须获得此对象的锁,才能调用这几个方法
之所以这三个方法都是在
Object
类中,是因为所有对象obj
都有可能成为synchronized(obj)
锁住的同步监视器,并且其睡眠与唤醒机制是通过 JVM 底层的Monitor
实现的(其需要和该obj
对象绑定使用,一个obj
对应一个Monitor
),因此obj
对象中必须包含这三个方法,所以 JDK 设计者就将这三个方法设计到Object
类中,这样所有的类都可以作为锁,都可以调用这三个方法进行睡眠与唤醒。关于该部分的具体原理见文章 【JUC】synchronized 原理
原理:调用 wait()
的线程会进入 Monitor
的等待集合 WaitSet
中,进入 WAITING
状态,并且释放锁 。一旦其他线程调用 notify()
,就会随机从该集合中唤醒一个线程,使得其进入阻塞队列 EntryList
中。阻塞队列中的线程将等待调用 notify()
的线程退出同步代码块或也进入 wait()
状态(本质就是释放锁)后进行非公平竞争抢锁,抢占成功就进入 RUNNABLE
状态,否则依旧进入阻塞队列等待其他线程释放锁。
虚假唤醒问题:wait()
方法的特点是“在哪里睡就在哪里醒”。如果wait()
方法不使用在 while 循环中,则当其被其他线程意外唤醒后,可能还没满足条件就继续向下执行了,这显然是不符合逻辑的。因此某些业务条件下需要将 wait()
放在 while 循环中,每次被唤醒后都判断下资源是否满足,防止其被别人意外唤醒后,在不满足跳出阻塞条件的情况下继续向下执行。
解决虚假唤醒问题的关键:
- while 循环自旋加条件判断,满足条件再退出循环执行业务代码
- 使用
notifyAll()
唤醒所有等待线程
解决虚假唤醒问题的方法示例:
1 | // 线程1 |
这时因为线程 1 添加了 while 循环不断自旋判断,所以无需担心虚假唤醒问题。
sleep() 和 wait() 的异同
- 相同点:一旦执行方法,都会释放 CPU 时间片,都会使得当前线程进入
WAITING
状态 - 不同点:
- 两个方法声明的位置不同:
Thread
类声明sleep()
,Object
类中声明wait()
- 调用的要求不同:
sleep()
可以在任何需要的场景下调用,wait()
必须在synchronized
中调用 - 关于是否释放锁:
sleep()
不会释放锁,wait()
会释放锁 Thread.sleep(xxx)
在时间结束后才会苏醒,wait(xxx)
可以在时间结束前被其他线程唤醒
- 两个方法声明的位置不同:
await()、signal()(lock.newCondition)
除了可以使用 Object
类中的 wait()
等方法外,还可以使用 Lock
接口提供的 Condition
接口(条件变量)进行线程通信,其好处是可以使用 condition.signal()
唤醒指定的线程(精准唤醒):
1 | Condition condition01 = lock.newCondition(); |
使用这种方式就可以避免虚假唤醒问题,因为其可以精准唤醒目标线程。
条件变量实现原理:每个条件变量其实就对应着一个等待队列,其实现类是 ConditionObject
。它是 AQS 的一个内部类,一个 lock
锁所在的 AQS 可以创建多个 ConditionObject
,每个 ConditionObject
都维护一个等待队列,用于存储睡眠的线程:
await()
流程:开始时 Thread-0 持有锁,调用 condition0.await()
时,进入 ConditionObject
的 addConditionWaiter
流程:创建新的 Node 状态为 -2(Node.CONDITION
),关联 Thread-0,加入等待队列尾部,进入 WAITING
状态,并且释放锁,阻塞等待被唤醒:
signal()
流程:假设线程 Thread-1 接下来获取到了锁,并调用 codition0.signal()
,则会唤醒该 Thread-0。并将该 Node 加入 AQS 双向队列的尾部,将 Thread-0 的 waitStatus
改为 0,Thread-3 的 waitStatus
改为 -1 ,代表其成功被唤醒:
park()/unpark()(LockSupport)
除了上面两种方式外,还可以使用 LockSupport
(锁支持)类来创建锁和其他同步类的基本线程阻塞原语。LockSuport
是 JUC 包下的一个线程阻塞工具类,所有的方法都是静态方法,并且底层使用了Unsafe
类的native
本地方法保证操作的原子性。
LockSupport
中的 park()
和 unpark(thread)
的作用分别是阻塞线程和解除阻塞线程。其功能比wait/notify
,await/signal
更强。
1 | // 暂停当前线程 |
原理:其使用了一种名为Permit
(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit),permit 只有两个值 1 和 0 ,默认是 0。可以把许可看成是一种信号量(Semaphore),但与 Semaphore 不同的是,许可的累加上限是 1。
permit
默认是0,所以一开始调用park()
方法,当前线程就会阻塞,直到别的线程将当前线程的permit
设置为1时,park()
方法会被唤醒,然后会将permit
再次设置为 0 并返回。- 若其他线程事先调用了
unpark(a)
,则 a 线程的permit
事先就变为了 1,其后续再调用park()
时就不会阻塞)
其底层结构:
每一个线程都在底层 C 语言中对应一个 Parker
对象,其内的 _counter
值就是前面介绍的许可 Permit
,通过 UNSAFE
类的原子性方法保证原子性修改该值,从而实现阻塞和唤醒功能。
wait()/notify()、await()/signal()、park()/unpark() 区别
wait()
和notify()
是Object
类中的方法,必须在synchronized(obj)
中使用,睡眠的线程保存在Monitor
的等待集合WaitSet
中。调用顺序必须是先wait()
后notify()
,否则线程无法被唤醒。无法精确唤醒,可能存在虚假唤醒问题await()
和signal()
是 AQS 中Condition
接口的方法。必须在reentranLock.lock()
包裹的同步代码块中使用,睡眠的线程保存在 AQS 中的ConditionObject
的等待队列中。调用顺序必须是先await()
后signal()
,否则线程无法被唤醒。可以精确唤醒park()
和unpark()
是LockSupport
类中的方法。不需要在同步代码块内就可以使用。调用顺序无所谓(底层使用Pemit
机制)
总结:前面两种方式必须在同步代码块中才可以使用,并且对调用的顺序有要求。而使用 LockSupport
就没有这两个限制。
应用
应用一:两个线程交替打印 1-100 数字。
思路:使用 wait()
和 notifyAll()
1 | package multiThread; |
其他写法:
1 | public class CyclePrint { |
应用二:三个线程交替打印:abcabcabcabc。其中,线程 1 输出 a 5 次,线程 2 输出 b 5 次,线程 3 输出 c 5 次。
共有三种方式可以实现该需求:
wait()
+notifyAll()
await()
+signal()
park()
+unpark()
方式一:wait()
+ notifyAll()
1 | public class AlternatePrinting01 { |
方式二:await()
+ signal()
1 | public class AlternatePrinting02 { |
方式三:park()
+ unpark()
1 | public class AlternatePrinting03 { |
LockSupport
线程等待和唤醒方式
3种让线程等待和唤醒的方法:
- 方式1:使用
Object
中的wait()
方法让线程等待,使用Object
中的notify()
方法唤醒线程 - 方式2:使用JUC包中
Condition
的await()
方法让线程等待,使用signal()
方法唤醒线程 - 方式3:使用
LockSupport
类的park()
和unpark()
阻塞当前线程以及唤醒指定被阻塞的线程
传统的synchronized
和Lock
实现等待唤醒通知的约束:
- 线程先要获得并持有锁,必须在锁块(
synchronized
或lock
)中 - 必须要先等待后唤醒,线程才能够被唤醒
前两种方式存在的问题:
Object
类中的wait()
和notify()
方法必须要在同步块或者方法里面且成对出现使用,否则会抛出java.lang.IllegalMonitorStateException
。并且调用顺序必须是先wait()
后notify()
,否则线程无法被唤醒。Condition
类中的await()
和signal()
方法必须要在同步块或者方法里面且成对出现使用,否则会抛出java.lang.IllegalMonitorStateException
。并且调用顺序必须是先await()
后signal()
,否则线程无法被唤醒。
第三种使用LockSupport
的方式则不会出现这些问题。
LockSupport 介绍
LockSupport
(锁支持)是用来创建锁和其他同步类的基本线程阻塞原语。LockSupport
中的 park()
和 unpark(thread)
的作用分别是阻塞线程和解除阻塞线程。其功能比wait/notify
,await/signal
更强。
LockSuport
是 JUC 包下的一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport
调用的Unsafe
中的native
代码。
Basic thread blocking primitives for creating locks and other synchronization classes.
This class associates, with each thread that uses it, a permit (in the sense of the Semaphore class). A call to park will return immediately if the permit is available, consuming it in the process; otherwise it may block. A call to unpark makes the permit available, if it was not already available. (Unlike with Semaphores though, permits do not accumulate. There is at most one.)
LockSupport
类通过park()
和unpark(thread)
方法来实现阻塞和唤醒线程的操作。
其使用了一种名为Permit
(许可)的概念来做到阻塞和唤醒线程的功能,每个线程都有一个许可(permit),permit只有两个值1和零,默认是零。可以把许可看成是一种信号量(Semaphore),但与Semaphore不同的是,许可的累加上限是1。
LockSupport 源码
park()
/park(Object blocker)
:阻塞当前线程 / 阻塞传入的具体线程。其底层使用了Unsafe
类的native
本地方法:
1 | public class LockSupport { |
permit
默认是0,所以一开始调用park()
方法,当前线程就会阻塞,直到别的线程将当前线程的permit
设置为1时,park()
方法会被唤醒,然后会将permit
再次设置为0并返回(若其他线程事先调用了unpark(a)
,则a线程的permit
事先就变为了1,其后续再调用park()
时就不会阻塞)
unpark(Thread thread)
:唤醒处于阻塞状态的指定线程
1 | public class LockSupport { |
调用unpark(thread)
方法后,就会将thread
线程的许可permit
设置成1(注意多次调用unpark()
方法,不会累加,pemit
值还是1)。从而自动唤醒thead
线程,即之前阻塞中的LockSupport.park()
方法会立即返回。
图解流程
- 初始状态(还未调用
park()
时):_counter = 0
- 调用
Unsafe.park()
方法- 检查
_counter
,初始时为 0。这时,获得_mutex
互斥锁 - 线程进入
_cond
条件变量阻塞 - 设置
_counter = 0
- 检查
- 调用
Unsafe.unpark(Thread_0)
方法- 设置
_counter
为 1 - 唤醒
_cond
条件变量中的 Thread_0 - Thread_0 恢复运行
- 设置
_counter
为 0
- 设置
LockSupport 案例解析
1 | public class LockSupportDemo { |
输出结果:
1 | Thread A come in. |
正常 + 无锁块要求。先前错误的先唤醒后等待顺序,LockSupport
可无视这顺序。
总结
LockSuport
是一个线程阻塞工具类,所有的方法都是静态方法,可以让线程在任意位置阻塞,阻塞之后也有对应的唤醒方法。归根结底,LockSupport
调用的Unsafe
中的native
代码。
LockSupport
提供park()
和unpark()
方法实现阻塞线程和解除线程阻塞的过程。LockSupport
和每个使用它的线程都有一个许可(permit
)关联。permit
相当于1,0的开关,默认是0,调用一次unpark()
就加1变成1,调用一次park()
会消费permit
,也就是将1变成0,同时park()
立即返回。
如再次调用park()
会变成阻塞(因为permit
为0了会阻塞在这里,一直到permit
变为1),这时调用unpark()
会把permit
置为1。每个线程都有一个相关的permit
,permit
最多只有一个,重复调用unpark()
也不会积累凭证。
形象的理解
线程阻塞需要消耗凭证(permit),这个凭证最多只有1个。当调用park()
方法时:
- 如果有凭证,则会直接消耗掉这个凭证然后正常退出。
- 如果无凭证,就必须阻塞等待凭证可用。
而unpark()
则相反,它会增加一个凭证,但凭证最多只能有1个,累加无效。
面试题:
- 为什么
LockSupport
可以先唤醒线程后阻塞线程:因为unpark(a)
让线程a先获得了一个凭证,之后a线程再调用park()
方法时,就可以名正言顺的凭证消费,故不会阻塞。 - 为什么
LockSupport
唤醒两次后阻塞两次,但最终结果还会阻塞线程:因为凭证的数量最多为1(不能累加),连续调用两次unpark()
和调用一次效果一样,只会增加一个凭证;而调用两次park()
却需要消费两个凭证,证不够,不能放行。
ThreadLocal
ThreadLocal
是 Java 所提供的的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部(ThreadLocalMap
对象内),该线程可以在任意时刻,任意方法栈位置获取缓存的数据。
ThreadLocal
底层是通过 ThreadLocalMap
实现的。每个线程对象 Thread
内部有一个 ThreadLocalMap
对象,其存储的 key:value
为"ThreadLocal
对象:需要缓存的值"。例如:
1 | private ThreadLocal<String> tl1 = new ThreadLocal(); |
调用 setName("hello")
时就会在当前线程的 ThreadLocalMap
对象中存储两个 key:value
:
1 | key(ThreadLocal 类型) : value(String 类型) |
潜在风险
如果在线程池中使用 ThreadLocal
,则会造成内存泄漏。因为线程池内的核心线程一直存活,其内的 ThreadLocalMap
会一直存储之前已经结束的任务保存的 ThreadLocal
数据,使得这些对象无法被 GC,从而会造成内存泄漏。
解决方案:在使用完 ThreadLocal
内存储的数据后,手动调用 remove()
方法清除其内存储的对象。
应用场景
ThreadLocal
的典型应用场景就是连接管理。一个线程持有一个连接,该链接对象可以在不同的方法之间传递,线程间不共享同一个连接。
应用:Spring Transaction
在 Spring 中,当一个新的事务创建时,就会被绑定到当前线程上。TransactionAspectSupport类中的ThreadLocal<TransactionInfo>
在当前线程保存了一个事务的信息 TransactionInfo:
这样就能做到在当前线程中获取到正在起作用的事务信息。之后从 ThreadLocal
中获取到 TransactionInfo
即可得知当前事务的信息。
该线程会伴随着这个事务整个生命周期,直到事务提交、回滚或挂起(临时解绑)时该线程才会取消与该事务的绑定。
应用:云商城
在项目中的应用 【Project】云商城-订单服务:在登录拦截其中将会员信息保存到 ThreadLocal
中,从而在 Controller 层和 Service 层能获取到会员信息:
- 自定义登录拦截器,若用户未登录则跳转到登录页面;若已登录则将用户信息存入
ThreadLocal
中在 Service 层获取:
1 | /** |
- Service 层获取到登陆拦截器所获取的会员信息:
1 |
|
多线程中的设计模式
同步模式之保护性暂停
即 Guarded Suspension,用在一个线程等待另一个线程的执行结果。保护性暂停模式是同步阻塞的(生产者消费者模式是异步阻塞的,放入阻塞队列的消息不会被立即消费)
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个
GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列(见生产者/消费者)
- JDK 中,
join
的实现、Future
的实现(future.get()
),采用的就是此模式 - 因为要等待另一方的结果,因此归类到同步模式
实现
1 | class GuardedObject { |
应用
一个线程等待另一个线程的执行结果。
1 | public static void main(String[] args) { |
多任务版 GuardedObject
图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的 t1,t3,t5 就好比邮递员。
如果需要在多个类之间使用 GuardedObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,
这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理。
同步模式之顺序控制
wait/notify
、await()/signal()
与 park()/unpark()
就是顺序控制模式的一种实现。
异步模式之生产者/消费者
- 与前面的保护性暂停中的
GuardObject
不同,不需要产生结果和消费结果的线程一一对应 - 消费队列可以用来平衡生产和消费的线程资源
- 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
- 消息队列是有容量限制的,满时不会再加入数据,空时不会再消耗数据
- JDK 中各种阻塞队列,采用的就是这种模式
实现
1 |
|
应用
1 | MessageQueue messageQueue = new MessageQueue(2); |
异步模式之工作线程
让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现
就是线程池,也体现了经典设计模式中的享元模式。
让有限的工作线程(Worker Thread)来轮流异步处理无限多的任务。也可以将其归类为分工模式,它的典型实现
就是线程池,也体现了经典设计模式中的享元模式。
终止模式之两阶段终止模式
Two Phase Termination:在一个线程 T1 中如何“优雅”终止线程 T2?这里的【优雅】指的是给 T2 一个料理后事的机会 。
错误思路:
- 使用线程对象的
stop()
方法停止线程:stop
方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁,其它线程将永远无法获取锁 - 使用
System.exit(int)
方法停止线程:目的仅是停止一个线程,但这种做法会让整个程序都停止
两阶段终止模式:分两个阶段终止线程
实现
1 | // 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性 |
调用:
1 | TPTVolatile t = new TPTVolatile(); |
变量和集合类的线程安全分析
成员变量和静态变量的线程安全分析
- 如果变量没有在线程间共享,那么变量是安全的
- 如果变量在线程间共享
- 如果只有读操作,则线程安全
- 如果有读写操作,则这段代码处于临界区,需要考虑线程安全
局部变量线程安全分析
- 基本数据类型的局部变量是安全的
- 引用类型的局部变量对象未必是安全的
- 如果局部变量引用的对象没有引用线程共享的对象,那么是线程安全的
- 如果局部变量引用的对象引用了一个线程共享的对象,那么要考虑线程安全的
保证类成员变量线程安全性的技巧:
- 使用
final
修饰成员变量或静态成员变量,这样该变量会在初始化时就赋好值,并且在之后就不能被修改了
常见线程安全类
String
Integer
StringBuffer
Random
Vector
Hashtable
java.util.concurrent
包下的类
String
和Integer
类都是不可变的类,因为其类内部状态是不可改变的,因此它们的方法都是线程安全的。
String
的replace
,substring
等方法被调用时返回的已经是一个新创建的对象了,所以能保证其自身的线程安全性。并且String
类是final
修饰的,代表不能有子类继承String
类,从而保护了其线程安全的特性。
这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为它们的每个方法是原子的。但注意它们多个方法的组合不是原子的。
保证类的线程安全的技巧:
- 使用
private
修饰想要保证线程安全性的方法,这样子类就无法继承该方法,无法破坏其安全性 - 使用
final
修饰想要保证线程安全性的方法,这样子类就无法重写该方法,无法破坏其安全性
private 修饰的方法虽然可以被子类所继承,但是其没有访问权限,无法破坏其安全性。子类再创建的同名方法就是新方法了
ArrayList 线程不安全
ArrayList
是线程不安全的,并发条件下对 ArrayList
的 add()
和 remove()
操作会导致异常发生。
解决方案一:使用 Vector
集合(较为古老,不常用)
Vector
是 List
接口的古老实现类,其特点是线程安全的,效率低,不太常用(底层是 synchronized
加锁方式的效率都比较低,因为不论是读还是写都会阻塞,而读操作没必要加锁)
解决方案二:使用 Collections
工具类(不常用)
Collections 是一个操作 Set
、List
和 Map
等集合的工具类(操作数组的工具类:Arrays
)。Collections
中提供了一系列静态的方法对集合元素进行排序、查询和修改等操作,还提供了对集合对象设置不可变、对集合对象实现同步控制等方法。
其提供的线程安全功能是通过 synchronized
方式加锁,其效率不高,也不常用。
1 | List<String> list = Collections.synchronizedList(new ArrayList<>()); |
解决方案三:使用 JUC 包下的 CopyOnWriteArrayList 类(常用)
它相当于线程安全的 ArrayList
。和 ArrayList
一样,它是个可变数组;但是和 ArrayList
不同的是,它具有以下特性:
- 它最适合于具有以下特征的应用程序:List 大小通常保持很小,只读操作远多于可变操作,需要在遍历期间防止线程间的冲突。
- 它是线程安全的。
- 因为通常需要复制整个基础数组,所以可变操作(
add()
、set()
和remove()
等等)的开销很大。 - 迭代器支持
hasNext()
,next()
等不可变操作,但不支持可变remove()
等操作。 - 使用迭代器进行遍历的速度很快,并且不会与其他线程发生冲突。在构造迭代器时,迭代器依赖于不变的数组快照。
CopyOnWriteArrayList
不使用独占锁,而是使用写时复制技术,将读写分离。读操作不加锁,写操作才加锁。
写时复制思路:当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样读和写的是不同的容器,因此实现了读写分离,写时不耽误并发读。
add()
方法源码:
拷贝时使用的是 Arrays
工具类的 copyOf()
方法。
总结:Vector
读写时都上锁,因此性能较差,不能同时读和写。但 CopyOnWriteArrayList 可以在读的时候进行写,效率较高。
HashSet 线程不安全
解决方案:使用 CopyOnWriteArraySet
HashMap 线程不安全
解决方案:使用 ConcurrentHashMap