Java 内存模型(JMM)#
Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。
只有堆中的共享变量才会受到JMM的影响,因为这些内存是线程共享的,而虚拟机栈和本地方法栈是线程私有,不熟JMM影响。
主内存与工作内存#
处理器上的寄存器的读写的速度比内存快几个数量级,为了解决这种速度矛盾,在它们之间加入了高速缓存。
加入高速缓存带来了一个新的问题:缓存一致性。如果多个缓存共享同一块主内存区域,那么多个缓存的数据可能会不一致,需要一些协议来解决这个问题。

所有的变量都存储在主内存中,每个线程还有自己的工作内存,工作内存存储在高速缓存或者寄存器中,保存了该线程使用的变量的主内存副本拷贝。
JMM只是一个小抽象的模型,本地内存只是一个抽象的概念,这些内存可能处于寄存器,Cache等地方。
线程只能直接操作工作内存中的变量,不同线程之间的变量值传递需要通过主内存来完成。

内存间交互操作#
Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

- read:把一个变量的值从主内存传输到工作内存中
- load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中
- use:把工作内存中一个变量的值传递给执行引擎
- assign:把一个从执行引擎接收到的值赋给工作内存的变量
- store:把工作内存的一个变量的值传送到主内存中
- write:在 store 之后执行,把 store 得到的值放入主内存的变量中
- lock:作用于主内存的变量
- unlock
内存模型三大特性#
1. 原子性#
Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。
有一个错误认识就是,int 等原子性的类型在多线程环境中不会出现线程安全问题。前面的线程不安全示例代码中,cnt 属于 int 类型变量,1000 个线程对它进行自增操作之后,得到的值为 997 而不是 1000。
为了方便讨论,将内存间的交互操作简化为 3 个:load、assign、store。
下图演示了两个线程同时对 cnt 进行操作,load、assign、store 这一系列操作整体上看不具备原子性,那么在 T1 修改 cnt 并且还没有将修改后的值写入主内存,T2 依然可以读入旧值。可以看出,这两个线程虽然执行了两次自增运算,但是主内存中 cnt 的值最后为 1 而不是 2。因此对 int 类型读写操作满足原子性只是说明 load、assign、store 这些单个操作具备原子性。

AtomicInteger 能保证多个线程修改的原子性。

使用 AtomicInteger 重写之前线程不安全的代码之后得到以下线程安全实现:
1 | public class AtomicExample { |
1 | public static void main(String[] args) throws InterruptedException { |
除了使用原子类之外,也可以使用 synchronized 互斥锁来保证操作的原子性。它对应的内存间交互操作为:lock 和 unlock,在虚拟机实现上对应的字节码指令为 monitorenter 和 monitorexit。
1 | public class AtomicSynchronizedExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | 1000 |
2. 可见性#
可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的。
主要有三种实现可见性的方式:
- volatile
- synchronized,对一个变量执行 unlock 操作之前,必须把变量值同步回主内存。
- final,被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。
对前面的线程不安全示例中的 cnt 变量使用 volatile 修饰,不能解决线程不安全问题,因为 volatile 并不能保证操作的原子性。
3. 有序性#
有序性是指:在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile 关键字通过添加内存屏障的方式来禁止指令重排,即重排序时不能把后面的指令放到内存屏障之前。
也可以通过 synchronized 来保证有序性,它保证每个时刻只有一个线程执行同步代码,相当于是让线程顺序执行同步代码。
先行发生原则#
上面提到了可以用 volatile 和 synchronized 来保证有序性。除此之外,JVM 还规定了先行发生原则,让一个操作无需控制就能先于另一个操作完成。
1. 单一线程原则#
Single Thread rule
在一个线程内,在程序前面的操作先行发生于后面的操作。

2. 管程锁定规则#
Monitor Lock Rule
一个 unlock 操作先行发生于后面对同一个锁的 lock 操作。

3. volatile 变量规则#
Volatile Variable Rule
对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作。

4. 线程启动规则#
Thread Start Rule
Thread 对象的 start() 方法调用先行发生于此线程的每一个动作。

5. 线程加入规则#
Thread Join Rule
Thread 对象的结束先行发生于 join() 方法返回。

6. 线程中断规则#
Thread Interruption Rule
对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过 interrupted() 方法检测到是否有中断发生。
7. 对象终结规则#
Finalizer Rule
一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
8. 传递性#
Transitivity
如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。
happens-before只是说代码执行的结果不影响, 在同一个线程内部,即使两个操作有先行发生关系,他们的执行顺序也是可能被重排序的,
只要两个操作之间没有数据依赖关系即可。但是这样的重排序在多线程中就可能造成程序混乱,因此再多线程环境下需要额外的手段来保障happens-before关系(volatile, sychnronized等)
volatile#
- volatile对任意变量的读写具有原子性(包括long),也就保证了可见性; 对volatile线程的写操作会将本地内存中的所有共享变量刷新到主内存中,使得其他线程立即可见;
读操作会废弃工作内存中的所有共享变量值,从主内存中重新读取数据;这样的原子性操作是通过lock指令实现的,即锁定总线,只允许自己一个线程操作内存。 - 为了实现上述语义,在volatile变量的写和读前后插入内存屏障,禁止可能造成volatile语义错误的重排序,这样也就保证了顺序性
synchronized,锁#
- 线程在获取锁的时候,会将本地内存中的共享变量置为无效,需要重新从主内存中读取数据;
- 线程在释放锁的时候,会将本地内存中的共享变量刷新到主内存,其他县城需要从主内存中读取数据;
- 可以看到volatile的写操作和多的释放对应,而volatile的读操作与锁的获取有相同的语义;
- ReentrantLock实际上就是使用了volatile变量state来实现了所操作,获取锁的时候读取state,释放锁的时候写state,这样就保证了内存语义;
final关键字#
- final关键字在构造函数中被初始化之后,在离构造函数之前,会被插入内存屏障,既保证了在构造函数完成之后,final对其它线程都是可见的,并且都是初始化之后的值;
- 线程在读final关键字读之前,会访问到对象,这两个操作不能被重排序,保证了正确性;
- 如果final变量引用的是对象,则除了以上约束,还附加了一个约束:在构造函数中读引用变量的成员赋值操作,也保证能够被其他线程可见
1 | class FinalTest{ |
- final变量如果在构造函数之内发生this逃逸,则可能会被其他线程看到为被初始化的值,因为构造函数内部的操作没有予以保证;
volatile-双锁检测#
使用双锁检测来实现单例模式,其中双锁检测是效率比较高的方案:
1 |
|
(1)处的检测不影响正确性,只是为了性能,避免频繁的加锁;
(2)是加锁放置两次初始化;
(3)是防止重复初始化;
(4)处的对象创建过程包含三个步骤:(a)分配内存;(b)初始化对象;(c)instance赋值;
在没有(1)的时候,代码是安全的,因为在对象被完全创建之前,另一个线程会等待加锁;
但是在加上(1)之后, 如果(b)和(c)重排序之后,可能导致另一个线程访问到未被初始化的对象;从而不进入加锁区域,直接返回未被初始化的对象,导致出错
因此以上代码中必须使用volatile限定instance,防止(b),(c)被重排序;
更详细的单例模式见笔记:设计模式
线程状态转换#

新建(New)#
创建后尚未启动,尚未调用start()方法。
可运行(Runnable)#
可能正在运行,也可能正在等待 CPU 时间片。
包含了操作系统线程状态中的 Running 和 Ready。包括等待IO等也处于运行状态;
阻塞(Blocked)#
等待获取一个排它锁,如果其线程释放了锁就会结束此状态。因此只有synchronized才会进入阻塞状态,ReentrantLock会进入等待状态;
无限期等待(Waiting)#
等待其它线程显式地唤醒,否则不会被分配 CPU 时间片。
进入方法 | 退出方法 |
---|---|
没有设置 Timeout 参数的 Object.wait() 方法 | Object.notify() / Object.notifyAll() |
没有设置 Timeout 参数的 Thread.join() 方法 | 被调用的线程执行完毕 |
LockSupport.park() 方法 | LockSupport.unpark(Thread) |
限期等待(Timed Waiting)#
无需等待其它线程显式地唤醒,在一定时间之后会被系统自动唤醒。
调用 Thread.sleep() 方法使线程进入限期等待状态时,常常用“使一个线程睡眠”进行描述。
调用 Object.wait() 方法使线程进入限期等待或者无限期等待时,常常用“挂起一个线程”进行描述。
睡眠和挂起是用来描述行为,而阻塞和等待用来描述状态。
阻塞和等待的区别在于,阻塞是被动的,它是在等待获取一个排它锁。而等待是主动的,通过调用 Thread.sleep() 和 Object.wait() 等方法进入。
进入方法 | 退出方法 |
---|---|
Thread.sleep() 方法 | 时间结束 |
设置了 Timeout 参数的 Object.wait() 方法 | 时间结束 / Object.notify() / Object.notifyAll() |
设置了 Timeout 参数的 Thread.join() 方法 | 时间结束 / 被调用的线程执行完毕 |
LockSupport.parkNanos() 方法 | LockSupport.unpark(Thread) |
LockSupport.parkUntil() 方法 | LockSupport.unpark(Thread) |
死亡(Terminated)#
可以是线程结束任务之后自己结束,或者产生了异常而结束。
使用线程#
有三种使用线程的方法:
- 实现 Runnable 接口;
- 实现 Callable 接口;
- 继承 Thread 类。
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。
实现 Runnable 接口#
需要实现 run() 方法。
通过 Thread 调用 start() 方法来启动线程。
1 | public class MyRunnable implements Runnable { |
1 | public static void main(String[] args) { |
实现 Callable 接口#
与 Runnable 相比,Callable 可以有返回值,返回值通过 FutureTask 进行封装。
1 | public class MyCallable implements Callable<Integer> { |
1 | public static void main(String[] args) throws ExecutionException, InterruptedException { |
继承 Thread 类#
同样也是需要实现 run() 方法,因为 Thread 类也实现了 Runable 接口。
当调用 start() 方法启动一个线程时,虚拟机会将该线程放入就绪队列中等待被调度,当一个线程被调度时会执行该线程的 run() 方法。
1 | public class MyThread extends Thread { |
1 | public static void main(String[] args) { |
实现接口 VS 继承 Thread#
实现接口会更好一些,因为:
- Java 不支持多重继承,因此继承了 Thread 类就无法继承其它类,但是可以实现多个接口;
- 类可能只要求可执行就行,继承整个 Thread 类开销过大。
基础线程机制#
Executor#
Executor 管理多个异步任务的执行,而无需程序员显式地管理线程的生命周期。这里的异步是指多个任务的执行互不干扰,不需要进行同步操作。
主要有三种 Executor:
- CachedThreadPool:一个任务创建一个线程;
- FixedThreadPool:所有任务只能使用固定大小的线程;
- SingleThreadExecutor:相当于大小为 1 的 FixedThreadPool。
1 | public static void main(String[] args) { |
Daemon#
守护线程是程序运行时在后台提供服务的线程,不属于程序中不可或缺的部分。
当所有非守护线程结束时,程序也就终止,同时会杀死所有守护线程。
main() 属于非守护线程。
使用 setDaemon() 方法将一个线程设置为守护线程。
1 | public static void main(String[] args) { |
sleep()#
Thread.sleep(millisec) 方法会休眠当前正在执行的线程,millisec 单位为毫秒。
sleep() 可能会抛出 InterruptedException,因为异常不能跨线程传播回 main() 中,因此必须在本地进行处理。线程中抛出的其它异常也同样需要在本地进行处理。
1 | public void run() { |
yield()#
对静态方法 Thread.yield() 的调用声明了当前线程已经完成了生命周期中最重要的部分,可以切换给其它线程来执行。该方法只是对线程调度器的一个建议,而且也只是建议具有相同优先级的其它线程可以运行。
1 | public void run() { |
中断#
一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束。
InterruptedException#
通过调用一个线程的 interrupt() 来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞。
对于以下代码,在 main() 中启动一个线程之后再中断它,由于线程中调用了 Thread.sleep() 方法,因此会抛出一个 InterruptedException,从而提前结束线程,不执行之后的语句。
1 | public class InterruptExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | Main run |
interrupted()#
如果一个线程的 run() 方法执行一个无限循环,并且没有执行 sleep() 等会抛出 InterruptedException 的操作,那么调用线程的 interrupt() 方法就无法使线程提前结束。
但是调用 interrupt() 方法会设置线程的中断标记,此时调用 interrupted() 方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程。
1 | public class InterruptExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | Thread end |
Executor 的中断操作#
调用 Executor 的 shutdown() 方法会等待线程都执行完毕之后再关闭,但是如果调用的是 shutdownNow() 方法,则相当于调用每个线程的 interrupt() 方法。
以下使用 Lambda 创建线程,相当于创建了一个匿名内部线程。
1 | public static void main(String[] args) { |
1 | Main run |
如果只想中断 Executor 中的一个线程,可以通过使用 submit() 方法来提交一个线程,它会返回一个 Future<?> 对象,通过调用该对象的 cancel(true) 方法就可以中断线程。
1 | Future<?> future = executorService.submit(() -> { |
互斥同步#
Java 提供了两种锁机制来控制多个线程对共享资源的互斥访问,第一个是 JVM 实现的 synchronized,而另一个是 JDK 实现的 ReentrantLock。
synchronized使用#
1. 同步一个代码块
1 | public void func() { |
它只作用于同一个对象,如果调用两个对象上的同步代码块,就不会进行同步。
对于以下代码,使用 ExecutorService 执行了两个线程,由于调用的是同一个对象的同步代码块,因此这两个线程会进行同步,当一个线程进入同步语句块时,另一个线程就必须等待。
1 | public class SynchronizedExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
对于以下代码,两个线程调用了不同对象的同步代码块,因此这两个线程就不需要同步。从输出结果可以看出,两个线程交叉执行。
1 | public static void main(String[] args) { |
1 | 0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9 |
2. 同步一个方法
1 | public synchronized void func () { |
它和同步代码块一样,作用于同一个对象。
3. 同步一个类
1 | public void func() { |
作用于整个类,也就是说两个线程调用同一个类的不同对象上的这种同步语句,也会进行同步。
1 | public class SynchronizedExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
4. 同步一个静态方法
1 | public synchronized static void fun() { |
作用于整个类。
synchronized原理:#
1. 对代码块的同步是通过monitorenter和monitorexit指令实现的:
monitorenter:每个对象都有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
- 如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
- 如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
- 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权;
monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
2. 对方法的同步是对方法设置ACC_SYNCHRONIZED标记。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
以上过程可以看出synchronized是可重入的,即同一个线程可以连续对同一个对象加锁;这样做的原因是为了避免死锁,比如递归调用;
synchronized优化#
Java对象都有一个对象头字段,里面包了MarkWord,类型指针,数字长度(可选)等信息,其中MarkWord就记录了synchronized锁的信息;
从JDK5引入了现代操作系统新增加的CAS原子操作( JDK5中并没有对synchronized关键字做优化,而是体现在J.U.C中,所以在该版本concurrent包有更好的性能 ),从JDK6开始,就对synchronized的实现机制进行了较大调整,包括使用JDK5引进的CAS自旋之外,还增加了自适应的CAS自旋、锁消除、锁粗化、偏向锁、轻量级锁这些优化策略。由于此关键字的优化使得性能极大提高,同时语义清晰、操作简单、无需手动关闭,所以推荐在允许的情况下尽量使用此关键字,同时在性能上此关键字还有优化的空间。

锁主要存在四种状态,依次是:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
在 JDK 1.6 中默认是开启偏向锁和轻量级锁的,可以通过-XX:-UseBiasedLocking来禁用偏向锁。
偏向锁:在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低,引进了偏向锁。偏向锁是在单线程执行代码块时使用的机制,如果在多线程并发的环境下(即线程A尚未执行完同步代码块,线程B发起了申请锁的申请),则一定会转化为轻量级锁或者重量级锁。引入偏向锁主要目的是:为了在没有多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子指令的,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗也必须小于节省下来的CAS原子指令的性能消耗)。
轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。
偏向锁不会主动释放锁,需要另一个线程竞争之后才会释放锁,一旦有另一个线程竞争锁,则会升级为轻量级锁;
轻量级锁:引入轻量级锁的主要目的是 在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤主要为:使用CAS操作更新LockWord指针指向的位置,CAS失败会检查是否是本线程已经持有锁,若是直接执行,否则说明有竞争,会自旋竞争锁,自旋完之后还没有得到锁则会为升级为重量级锁;
对于轻量级锁,其性能提升的依据是 “对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果打破这个依据则除了互斥的开销外,还有额外的CAS操作,因此在有多线程竞争的情况下,轻量级锁比重量级锁更慢。
轻量级锁所适应的场景是线程交替执行同步块的情况,如果存在同一时间访问同一锁的情况,必然就会导致轻量级锁膨胀为重量级锁。
重量级锁: Synchronized是通过对象内部的一个叫做 监视器锁(Monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的Mutex Lock来实现的。而操作系统实现线程之间的切换这就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是为什么Synchronized效率低的原因。因此,这种依赖于操作系统Mutex Lock所实现的锁我们称之为 “重量级锁”。
synchronied锁状态总结:#
1. 如果是单线程使用,那偏向锁毫无疑问代价最小,并且它就能解决问题,连CAS都不用做,仅仅在内存中比较下对象头就可以了;
2. 如果出现了其他线程竞争,则偏向锁就会升级为轻量级锁;
3. 如果其他线程通过一定次数的CAS尝试没有成功,则进入重量级锁;
在第3种情况下进入同步代码块就 要做偏向锁建立、偏向锁撤销、轻量级锁建立、升级到重量级锁,最终还是得靠重量级锁来解决问题,那这样的代价就比直接用重量级锁要大不少了。所以使用哪种技术,一定要看其所处的环境及场景,在绝大多数的情况下,偏向锁是有效的,这是基于HotSpot作者发现的“大多数锁只会由同一线程并发申请”的经验规律。

偏向锁和轻量级锁都是依赖CAS操作来获取锁,因此没有内核态的切换,但是会消耗CPu资源空转,因此在有很多竞争的情况下,会升级为重量级锁;
重量级锁是依赖管城实现的(Monitor),因此所有的操作都需要和对象绑定,依赖于JVM,wait(), notify()等操作必须实现在对象上,也就是Object()中;
其它优化:#
自旋锁:使用CAS操作来实现锁,避免了内核切换,线程阻塞的开销;但是会消耗CPU资源;指定固定的自旋次数
自适应自旋锁:JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。那它如何进行适应性自旋呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。锁消除:为了保证数据的完整性,在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。
使用逃逸分析技术判定不存在数据竞争的锁
锁消除是指对于被检测出不可能存在竞争的共享数据的锁进行消除。
锁消除主要是通过逃逸分析来支持,如果堆上的共享数据不可能逃逸出去被其它线程访问到,那么就可以把它们当成私有数据对待,也就可以将它们的锁进行消除。
对于一些看起来没有加锁的代码,其实隐式的加了很多锁。例如下面的字符串拼接代码就隐式加了锁:
1
2
3
public static String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}
String 是一个不可变的类,编译器会对 String 的拼接自动优化。在 JDK 1.5 之前,会转化为 StringBuffer 对象的连续 append() 操作:
1
2
3
4
5
6
7
public static String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}
每个 append() 方法中都有一个同步块。虚拟机观察变量 sb,很快就会发现它的动态作用域被限制在 concatString() 方法内部。也就是说,sb 的所有引用永远不会逃逸到 concatString() 方法之外,其他线程无法访问到它,因此可以进行消除。
锁粗化:在使用同步锁的时候,需要让同步块的作用范围尽可能小—仅在共享数据的实际作用域中才进行同步,这样做的目的是 为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗话的概念。
锁粗话概念比较好理解,就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁
如果一系列的连续操作都对同一个对象反复加锁和解锁,频繁的加锁操作就会导致性能损耗。
上一节的示例代码中连续的 append() 方法就属于这类情况。如果虚拟机探测到由这样的一串零碎的操作都对同一个对象加锁,将会把加锁的范围扩展(粗化)到整个操作序列的外部。对于上一节的示例代码就是扩展到第一个 append() 操作之前直至最后一个 append() 操作之后,这样只需要加锁一次就可以了。
synchronized优化总结:自旋锁,自适应自旋锁,锁消除,锁粗化;偏向锁,轻量级锁。 注意这里自旋锁是指获取锁的方式,轻量级锁是锁的状态,轻量级锁通过CAS竞争锁失败,会利用自旋锁来竞争锁;
ReentrantLock#
ReentrantLock 是 java.util.concurrent(J.U.C)包中的锁。
1 | public class LockExample { |
1 | public static void main(String[] args) { |
1 | 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 |
RentrantLock实现可重入的方式:每个锁都关联一个计数器当计数器为时,没有线程持有锁,此时线程可以直接获得所,计数器加1;
持有线程继续获取锁,会先检测是不是现在持有锁的线程,是的话计数加一,否则线程等待;线程释放锁的时候,计数器减1,直到释放完所有的锁,计数器为0;回到释放状态;
比较synchtonized和ReentrantLock#
1. 锁的实现
synchronized 是 JVM 实现的,而 ReentrantLock 是 JDK 实现的。
2. 性能
新版本 Java 对 synchronized 进行了很多优化,例如自旋锁等,synchronized 与 ReentrantLock 大致相同。
3. 等待可中断
当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
ReentrantLock 可中断,而 synchronized 不行。
4. 公平锁
公平锁是指多个线程在等待同一个锁时,必须按照申请锁的时间顺序来依次获得锁。
synchronized 中的锁是非公平的,ReentrantLock 默认情况下也是非公平的,但是也可以是公平的。
5. 锁绑定多个条件
一个 ReentrantLock 可以同时绑定多个 Condition 对象。
使用选择#
除非需要使用 ReentrantLock 的高级功能,否则优先使用 synchronized。这是因为 synchronized 是 JVM 实现的一种锁机制,JVM 原生地支持它,而 ReentrantLock 不是所有的 JDK 版本都支持。并且使用 synchronized 不用担心没有释放锁而导致死锁问题,因为 JVM 会确保锁的释放。
非阻塞同步#
互斥同步最主要的问题就是线程阻塞和唤醒所带来的性能问题,因此这种同步也称为阻塞同步。
互斥同步属于一种悲观的并发策略,总是认为只要不去做正确的同步措施,那就肯定会出现问题。无论共享数据是否真的会出现竞争,它都要进行加锁(这里讨论的是概念模型,实际上虚拟机会优化掉很大一部分不必要的加锁)、用户态核心态转换、维护锁计数器和检查是否有被阻塞的线程需要唤醒等操作。
CAS#
随着硬件指令集的发展,我们可以使用基于冲突检测的乐观并发策略:先进行操作,如果没有其它线程争用共享数据,那操作就成功了,否则采取补偿措施(不断地重试,直到成功为止)。这种乐观的并发策略的许多实现都不需要将线程阻塞,因此这种同步操作称为非阻塞同步。
乐观锁需要操作和冲突检测这两个步骤具备原子性,这里就不能再使用互斥同步来保证了,只能靠硬件来完成。硬件支持的原子性操作最典型的是:比较并交换(Compare-and-Swap,CAS)。CAS 指令需要有 3 个操作数,分别是内存地址 V、旧的预期值 A 和新值 B。当执行操作时,只有当 V 的值等于 A,才将 V 的值更新为 B。
Atomic原子类#
13个原子类:
AtomicInteger, AtomicLong, AtomicBoolean#
J.U.C 包里面的整数原子类 AtomicInteger 的方法调用了 Unsafe 类的 CAS 操作。
以下代码使用了 AtomicInteger 执行了自增的操作。
1 | private AtomicInteger cnt = new AtomicInteger(); |
以下代码是 incrementAndGet() 的源码,它调用了 Unsafe 的 getAndAddInt() 。
1 | public final int incrementAndGet() { |
以下代码是 getAndAddInt() 源码,var1 指示对象内存地址,var2 指示该字段相对对象内存地址的偏移,var4 指示操作需要加的数值,这里为 1。通过 getIntVolatile(var1, var2) 得到旧的预期值,通过调用 compareAndSwapInt() 来进行 CAS 比较,如果该字段内存地址中的值等于 var5,那么就更新内存地址为 var1+var2 的变量为 var5+var4。
可以看到 getAndAddInt() 在一个循环中进行,发生冲突的做法是不断的进行重试。
1 | public final int getAndAddInt(Object var1, long var2, int var4) { |
AtomicIntegerArray, AtomicLongArray, AtomicReferenceArray#
保证对数组元素更新的原子性
1 | int[] value = new int[10]; |
value会被复制一份,atomic的更改不会反映到value中;
AtomicReference, AtomicReferenceFieldUpdater, AtomicMarkableReference#
可以保证对象的原子操作:原子更新引用,原子更新对象的引用字段,原子更新标记引用
AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, AtomicStampedReference#
要更新的字段一定要用public vloatile修饰
AtomicStampedReference加时间戳解决下面的ABA问题
CAS的问题#
ABA#
如果一个变量初次读取的时候是 A 值,它的值被改成了 B,后来又被改回为 A,那 CAS 操作就会误认为它从来没有被改变过。通过加时间戳来解决。
J.U.C 包提供了一个带有标记的原子引用类 AtomicStampedReference 来解决这个问题,它可以通过控制变量值的版本来保证 CAS 的正确性。
大部分情况下 ABA 问题不会影响程序并发的正确性,如果需要解决 ABA 问题,改用传统的互斥同步可能会比原子类更高效。
消耗CPU资源#
CAS长时间不成功,CPU开销大
只能保证一个变量的原子性#
可以使用AtomicReference将变量放入同一个类中来解决;
无同步方案#
要保证线程安全,并不是一定就要进行同步。如果一个方法本来就不涉及共享数据,那它自然就无须任何同步措施去保证正确性。
1. 栈封闭#
多个线程访问同一个方法的局部变量时,不会出现线程安全问题,因为局部变量存储在虚拟机栈中,属于线程私有的。
1 | public class StackClosedExample { |
1 | public static void main(String[] args) { |
1 | 100 |
2. 线程本地存储(Thread Local Storage)#
如果一段代码中所需要的数据必须与其他代码共享,那就看看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样,无须同步也能保证线程之间不出现数据争用的问题。
符合这种特点的应用并不少见,大部分使用消费队列的架构模式(如“生产者-消费者”模式)都会将产品的消费过程尽量在一个线程中消费完。其中最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式,这种处理方式的广泛应用使得很多 Web 服务端应用都可以使用线程本地存储来解决线程安全问题。
可以使用 java.lang.ThreadLocal 类来实现线程本地存储功能。
对于以下代码,SimpleDateFormat是线程不安全的,不能设置成static变量在线程间共享,因此需要使用ThreadLocal变量。
1 | private static final ThreadLocal<SimpleDateFormat> df = new ThreadLocal<SimpleDateFormat>(){ |
为了理解 ThreadLocal,先看以下代码:
1 | public class ThreadLocalExample1 { |
它所对应的底层结构图为:

每个 Thread 都有一个 ThreadLocal.ThreadLocalMap 对象。
1 | /* ThreadLocal values pertaining to this thread. This map is maintained |
当调用一个 ThreadLocal 的 set(T value) 方法时,先得到当前线程的 ThreadLocalMap 对象,然后将 ThreadLocal->value 键值对插入到该 Map 中。
1 | public void set(T value) { |
get() 方法类似。
1 | public T get() { |
ThreadLocal 从理论上讲并不是用来解决多线程并发问题的,因为根本不存在多线程竞争。
ThreadLocal的实现是这样的:每个Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal实例本身,value 是真正需要存储的 Object。
也就是说 ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。值得注意的是 ThreadLocalMap 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。
在一些场景 (尤其是使用线程池) 下,由于 ThreadLocal.ThreadLocalMap 的底层数据结构导致 ThreadLocal 有内存泄漏的情况,应该尽可能在每次使用 ThreadLocal 后手动调用 remove(),以避免出现 ThreadLocal 经典的内存泄漏甚至会造成自身业务混乱的风险。
内存泄漏分析:ThreadLocal中的对象是存放到线程的ThreadLocalMap类型变量threadLocals里面的,其中key为ThreadLocal对象,value就是我们保存的对象,需要注意的是threadLocals的key是弱引用;
因此一般情况下,threadLocals里面的key有一个请引用(ThreadLocal变量),和一个弱引用,当线程结束的时候,会设置ThreadLocal变量的引用为null.此时key就只剩一个弱引用了,会被GC回收,成为一个null值;
后续调用set(), get(), remove()的时候ThreadLocaMap变量会自动清除这些无效key对应的value; (注意这里threadLocal置空了,不调用后续这些方法,value无法被回收,因此也可以看成是内存泄漏;但不是由于弱引用带来的问题!)
弱引用的作用是,当外部的ThreadLocal强引用被清空之后,ThreadLocalMap中的弱引用就没作用了,后续操作就可以清除这些变量,否则无法做到这一点;
当线程结束的时候,线程会将threadLocals置为null,这样就能够自动的清除所有变量;
内存泄露的情况:当使用线程池的时候,线程是不会结束的,因此ThreadLocal引用一直存在,线程也不会结束(线程结束自动清理threadLocals);如果该变量已经不会再使用了,ThreadLocalMap对象也不能回收这些无用的变量,造成内存泄漏;
因此在使用ThreadLocal的时候,一定注意使用remove清除无用的变量;
3. 可重入代码(Reentrant Code)#
这种代码也叫做纯代码(Pure Code),可以在代码执行的任何时刻中断它,转而去执行另外一段代码(包括递归调用它本身),而在控制权返回后,原来的程序不会出现任何错误。
可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和公用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
线程之间的协作#
当多个线程可以一起工作去解决某个问题时,如果某些部分必须在其它部分之前完成,那么就需要对线程进行协调。
join()#
在线程中调用另一个线程的 join() 方法,会将当前线程挂起,而不是忙等待,直到目标线程结束。
对于以下代码,虽然 b 线程先启动,但是因为在 b 线程中调用了 a 线程的 join() 方法,b 线程会等待 a 线程结束才继续执行,因此最后能够保证 a 线程的输出先于 b 线程的输出。
1 | public class JoinExample { |
1 | public static void main(String[] args) { |
1 | A |
wait() notify() notifyAll()#
调用 wait() 使得线程等待某个条件满足,线程在等待时会被挂起,当其他线程的运行使得这个条件满足时,其它线程会调用 notify() 或者 notifyAll() 来唤醒挂起的线程。
它们都属于 Object 的一部分,而不属于 Thread。
只能用在同步方法或者同步控制块中使用,否则会在运行时抛出 IllegalMonitorStateException。
使用 wait() 挂起期间,线程会释放锁。这是因为,如果没有释放锁,那么其它线程就无法进入对象的同步方法或者同步控制块中,那么就无法执行 notify() 或者 notifyAll() 来唤醒挂起的线程,造成死锁。
线程执行wait()操作,释放锁,进入等待队列,线程状态变为等待状态(WAITING);
线程执行notify(),会将一个线程移入同步队列,该线程从等待状态变为阻塞状态,notify()返回不会使得wait()立即返回,需要等到notify()线程释放锁(离开同步块),并且该线程得到锁wait()才会返回;
notifyAll()会将所有等待的线程移入同步队列;
1 | public class WaitNotifyExample { |
1 | public static void main(String[] args) { |
1 | before |
几个问题:
- wait(), notify()为什么要在Object中实现: 这和设计有关,notify,wait必须在synchronized同步块中使用,而synchronized锁可以是任意对象,所以可以被任意对象调用的方法是定义在Object类中;
- wait(), notify()方法在对类进行锁定的时候,需要调用类的方法,而不是对象的方法:
1 |
|
wait() 和 sleep() 的区别
- wait() 是 Object 的方法,而 sleep() 是 Thread 的静态方法;
- wait() 会释放锁,sleep() 不会。
await() signal() signalAll()#
java.util.concurrent 类库中提供了 Condition 类来实现线程之间的协调,可以在 Condition 上调用 await() 方法使线程等待,其它线程调用 signal() 或 signalAll() 方法唤醒等待的线程。
相比于 wait() 这种等待方式,await() 可以指定等待的条件,因此更加灵活。
使用 Lock 来获取一个 Condition 对象。
1 | public class AwaitSignalExample { |
1 | public static void main(String[] args) { |
1 | before |
CountDownLatch#
用来控制一个线程等待多个线程。
维护了一个计数器 cnt,每次调用 countDown() 方法会让计数器的值减 1,减到 0 的时候,那些因为调用 await() 方法而在等待的线程就会被唤醒。

1 | public class CountdownLatchExample { |
1 | run..run..run..run..run..run..run..run..run..run..end |
CyclicBarrier#
用来控制多个线程互相等待,只有当多个线程都到达时,这些线程才会继续执行。
和 CountdownLatch 相似,都是通过维护计数器来实现的。线程执行 await() 方法之后计数器会减 1,并进行等待,直到计数器为 0,所有调用 await() 方法而在等待的线程才能继续执行。
CyclicBarrier 和 CountdownLatch 的一个区别是,CyclicBarrier 的计数器通过调用 reset() 方法可以循环使用,所以它才叫做循环屏障。
CyclicBarrier 有两个构造函数,其中 parties 指示计数器的初始值,barrierAction 在所有线程都到达屏障的时候会执行一次。
1 | public CyclicBarrier(int parties, Runnable barrierAction) { |

1 | public class CyclicBarrierExample { |
1 | before..before..before..before..before..before..before..before..before..before..after..after..after..after..after..after..after..after..after..after.. |
Semaphore#
Semaphore 类似于操作系统中的信号量,可以控制对互斥资源的访问线程数。
以下代码模拟了对某个服务的并发请求,每次只能有 3 个客户端同时访问,请求总数为 10。
1 | public class SemaphoreExample { |
1 | 2 1 2 2 2 2 2 1 2 2 |
J.U.C - AQS#
java.util.concurrent(J.U.C)大大提高了并发性能,AQS 被认为是 J.U.C 的核心。
AQS原理:https://blog.csdn.net/pfnie/article/details/53191892
总的来说,AQS核心思想是, 如果被请求的共享资源限制的话,直接将请求的线程设为有效的工作线程,并将资源状态设置为锁定状态;如果请求的资源被占用,会将线程加入同步队列中,并且通过LockSupport()将线程状态转成等待状态,直到其他线程唤醒并获取到锁为止;
- AQS使用一个state变量表示同步状态,获取锁的线程通过CAS操作修改state的值,state使用volatile修饰实现可见性;
- AQS使用模板方法,子类需要自己实现以下方法来实现不同的同步功能:
- isHeldExclusively(): 线程是否正在占用资源,主要用于Condition()
- tryAcquire(int): 独占方式尝试获取资源,成功返回true, 失败返回false
- tryRelease(int): 独占芳时释放锁
- tryAcquireShared(int): 共享方式获取锁
- tryReleaseShared(int): 共享方式释放锁
ReentrantLock的实现:#
RenetrantLock中实现了AQS的子类Sync,同时又从Sync实现了连个子类NonFairSync和FairSync以支持公平锁和非公平锁;
- 公平锁:初始状态下,sync state为0, A线程获取锁,调用tryAcquire()方法成功,将state+1;
如果A继续获取锁,由于与当前占用线程相同,因此tryAcquire()成功,state+1;
如果另一个线程获取锁,先执行acquire(), 在tryAcquire()中会失败,此时线程会被加入到同步队列中,并阻塞等待;
A完全释放完锁之后,会唤醒同步队列中Head节点的下一个节点中的进程,被唤醒的节点会成为新的Head;
Head节点在刚开始是一个空的节点,即没有线程的假节点,后续会成为占有锁的节点;
示例:ReentrantLock公平锁执行流程:
1 | lock1=>start: Reentrant.lock() |
1 |
|
锁释放的时候会将离head节点最近的等待节点唤醒执行,被唤醒的节点获取锁之后,会将自己设为head,便于后续唤醒下一个节点;
1 |
|
非公平锁于公平锁之间的差别就是,在tryAcquire中,非公平锁可以直接CAS设置状态获取锁,公平锁需要先检查等待队列中是否已有线程在等待,有的话将不会竞争锁;
读写锁#
读操作是可以共享的,但是写操作必须独占
LockSupport#
用于阻塞,恢复当前线程:park(), unpark(), 很重要的一点在于,unpark()和park()调用顺序互换不会导致死锁,类似于信号量;
signal(), await()等就是基于LockSupport实现的,因此这两个接口不会导致死锁;而suspend(), resume()需要保证调用顺序,是已经废弃的方法;
Condition接口#
signal(), signalAll(), await()
J.U.C - 并发容器和框架#
ConcurrentHashMap#
详见Java容器
BlockingQueue#
java.util.concurrent.BlockingQueue 接口有以下阻塞队列的实现:
- FIFO 队列 :LinkedBlockingQueue、ArrayBlockingQueue(固定长度)
- 优先级队列 :PriorityBlockingQueue
- 双端队列 : LinkedBlockingDeque
在队列为空和队列满的时候,提供了四中处理方法,如下:
方法 / 处理方式 | 抛出异常 | 返回特殊值 | 一直阻塞 | 超时退出 |
---|---|---|---|---|
添加 | add(e) | offer(e) | put(e) | offer(e, time, unit) |
删除 | remove() | poll() | take() | poll(time, unit) |
检查 | element() | peek() | 不支持 | 不支持 |
主要是提供了阻塞的 take() 和 put() 方法:如果队列为空 take() 将阻塞,直到队列中有内容;如果队列为满 put() 将阻塞,直到队列有空闲位置。
实现原理#
使用ReentrantLock和两个Condition实现的,经典的生产者消费者实现方式
lock负责获取锁
在队列满的时候,notFull阻塞添加元素的线程
在队列空的时候,notEmpty阻塞取元素的线程
元素加入的时候,notEmpty通知阻塞在notEmpty的线程取数据
元素取出的时候,notFull通知阻塞在notFull的线程添加元素
1 |
|
使用 BlockingQueue 实现生产者消费者问题
1 | public class ProducerConsumer { |
1 | public static void main(String[] args) { |
1 | produce..produce..consume..consume..produce..consume..produce..consume..produce..consume.. |
FutureTask#
在介绍 Callable 时我们知道它可以有返回值,返回值通过 Future
1 | public class FutureTask<V> implements RunnableFuture<V> |
1 | public interface RunnableFuture<V> extends Runnable, Future<V> |
FutureTask 可用于异步获取执行结果或取消执行任务的场景。当一个计算任务需要执行很长时间,那么就可以用 FutureTask 来封装这个任务,主线程在完成自己的任务之后再去获取结果。
1 | public class FutureTaskExample { |
1 | other task is running... |
ForkJoin#
主要用于并行计算中,和 MapReduce 原理类似,都是把大的计算任务拆分成多个小任务并行计算。
1 | public class ForkJoinExample extends RecursiveTask<Integer> { |
1 | public static void main(String[] args) throws ExecutionException, InterruptedException { |
ForkJoin 使用 ForkJoinPool 来启动,它是一个特殊的线程池,线程数量取决于 CPU 核数。
1 | public class ForkJoinPool extends AbstractExecutorService |
ForkJoinPool 实现了工作窃取算法来提高 CPU 的利用率。每个线程都维护了一个双端队列,用来存储需要执行的任务。工作窃取算法允许空闲的线程从其它线程的双端队列中窃取一个任务来执行。窃取的任务必须是最晚的任务,避免和队列所属线程发生竞争。例如下图中,Thread2 从 Thread1 的队列中拿出最晚的 Task1 任务,Thread1 会拿出 Task2 来执行,这样就避免发生竞争。但是如果队列中只有一个任务时还是会发生竞争。

线程池#
使用线程池的好处
- 降低资源消耗: 重复利用线程,减小线程创建和销毁带来的开销
- 提高响应速度:任务到达时,不需要新建线程执行,能够得到立即执行
- 提高线程的可管理性:线程如果随意创建,会消耗系统资源,也会降低系统的稳定性,线程池可以帮助管理
线程池执行流程#

线程池的增长策略#
当一个任务提交到线程池时:
1.如果此时线程池中的线程数量小于corePoolSize,即使线程池中的线程处于空闲状态,也要直接新建线程来处理被添加的任务。
2.如果线程池中的线程数量等于corePoolSize, 则找到空闲线程进行执行;如果所有线程都相处与运行状态,则查看任务队列是否已满,如果队列不满,则将任务放入队列。
3.如果线程池中的线程数量大于等于corePoolSize, 任务队列如果已满,并且线程池中的线程数小于maximumPoolSize,则仙剑线程执行任务。
4.如果线程池中线程数等于maximumPoolSize,且任务队列已满,则通过handler执行拒绝策略。也就是处理的优先级是:corePoolSize核心线程,任务队列workQueue, 最大线程maximumPoolSize.
如果三者都满了则使用handler执行拒绝策略。
线程销毁:当线程池中的线程数大于corePoolSize时,如果某县城空闲时间超过keepAliveTime,线程就将被终止,这样线程就可以动态调整池中的线程数。
线程池的拒绝策略#
- AbortPolicy: 直接抛出异常,并丢弃任务
- CallerRunsPolicy: 不想丢弃任务,直接使用提交任务的线程执行任务,会造成当前线程被阻塞。
- DiscardPolicy: 与AbortPolicy几乎一样,不同点在于,该策略不会抛出异常。
- DiscardOldestPolicy: 丢弃最老的请求,也就是队列中最近要执行的下一个任务,并重新尝试提交当前任务。
fail-fast 和 fail-safe#
一种Java集合中的错误机制,当多个线程对同一个集合进行操作时,可能就会产生fail-fast事件。
fail-fast#
遍历一个集合的同时,集合中的元素被改变(比如另一个线程调用add, remove, clear等会导致modCount发生改变的方法时,当然也可能是同一个线程在遍历的同时修改集合)
就会触发fail-fast机制。modCount是集合类中用于记录集合元素修改次数的变量,调用add, clear, remove都会增加modcount。 集合遍历的时候,会首先记录初始的modCount,遍历过程中如果这个值发生了改变则会抛出异常ConcurrentModification异常。
fail-safe#
为了解决不同在遍历时修改集合元素的问题,可以使用JUC下面的集合,这些集合在遍历时会先复制集合元素再进行遍历。
缺点:由于遍历是在原实际和的拷贝上进行的,集合遍历过程中无法访问到最新的元素。
线程不安全示例#
如果多个线程对同一个共享数据进行访问而不采取同步操作的话,那么操作的结果是不一致的。
以下代码演示了 1000 个线程同时对 cnt 执行自增操作,操作结束之后它的值有可能小于 1000。
1 | public class ThreadUnsafeExample { |
1 | public static void main(String[] args) throws InterruptedException { |
1 | 997 |
线程安全#
多个线程不管以何种方式访问某个类,并且在主调代码中不需要进行同步,都能表现正确的行为。
线程安全有以下几种实现方式:
不可变#
不可变(Immutable)的对象一定是线程安全的,不需要再采取任何的线程安全保障措施。只要一个不可变的对象被正确地构建出来,永远也不会看到它在多个线程之中处于不一致的状态。多线程环境下,应当尽量使对象成为不可变,来满足线程安全。
不可变的类型:
- final 关键字修饰的基本数据类型
- String
- 枚举类型
- Number 部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型。但同为 Number 的原子类 AtomicInteger 和 AtomicLong 则是可变的。
对于集合类型,可以使用 Collections.unmodifiableXXX() 方法来获取一个不可变的集合。
1 | public class ImmutableExample { |
1 | Exception in thread "main" java.lang.UnsupportedOperationException |
Collections.unmodifiableXXX() 先对原始的集合进行拷贝,需要对集合进行修改的方法都直接抛出异常。
1 | public V put(K key, V value) { |
多线程开发良好的实践#
给线程起个有意义的名字,这样可以方便找 Bug。
缩小同步范围,从而减少锁争用。例如对于 synchronized,应该尽量使用同步块而不是同步方法。
多用同步工具少用 wait() 和 notify()。首先,CountDownLatch, CyclicBarrier, Semaphore 和 Exchanger 这些同步类简化了编码操作,而用 wait() 和 notify() 很难实现复杂控制流;其次,这些同步类是由最好的企业编写和维护,在后续的 JDK 中还会不断优化和完善。
使用 BlockingQueue 实现生产者消费者问题。
多用并发集合少用同步集合,例如应该使用 ConcurrentHashMap 而不是 Hashtable。
使用本地变量和不可变类来保证线程安全。
使用线程池而不是直接创建线程,这是因为创建线程代价很高,线程池可以有效地利用有限的线程来启动任务。
参考资料#
- BruceEckel. Java 编程思想: 第 4 版 [M]. 机械工业出版社, 2007.
- 周志明. 深入理解 Java 虚拟机 [M]. 机械工业出版社, 2011.
- Threads and Locks
- 线程通信
- Java 线程面试题 Top 50
- BlockingQueue
- thread state java
- CSC 456 Spring 2012/ch7 MN
- Java - Understanding Happens-before relationship
- 6장 Thread Synchronization
- How is Java’s ThreadLocal implemented under the hood?
- Concurrent
- JAVA FORK JOIN EXAMPLE
- 聊聊并发(八)——Fork/Join 框架介绍
- Eliminating SynchronizationRelated Atomic Operations with Biased Locking and Bulk Rebiasing