参考
- 《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》
- 《Java并发编程的艺术》
- 《Java高级程序员面试笔试宝典》
- CyC2018/CS-Notes
- 面试官:说说什么是线程安全?一图带你了解java线程安全
1. Java内存模型
- Java 内存模型(Java Memoty Model,
JMM
)可以屏蔽各种硬件和操作系统的内存访问差异
,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果,从而使Java的并发编程达到跨平台
的效果。如果使用C语言等直接对操作系统的内存模型进行操作,可能需要编写不同的代码。 - Java线程之间的数据通信需要依靠
共享变量
来实现
如图所示,所有的共享变量
存储在主内存中,每条线程还有自己的工作内存
,线程对共享变量的操作(读写等)需要先拷贝
(拷贝的是对象引用、需要操作的对象的成员变量
,不会拷贝整个对象)到自己的工作内存中,而不能直接读写主内存中的数据,操作完成后,可以将该变量的值同步回主内存的共享变量中,对共享变量的读写
需要通过JMM
来完成,不同的线程之间也无法直接访问对方工作内存中的变量。 - 由于多个线程需要操作共享内存,由此引发了
线程安全问题
。
2. 线程安全
- 线程不安全的实例
public class ThreadUnSafe {
public static int sharedVar = 0;
public static void main(String[] args) {
ExecutorService threadPoolExecutor = Executors.newFixedThreadPool(2);
for (int i = 0; i < 2; i++) {
threadPoolExecutor.execute(() -> {
for (int i1 = 0; i1 < 10000; i1++) {
sharedVar++;
}
});
}
threadPoolExecutor.shutdown();
while (!threadPoolExecutor.isTerminated()) {
}
System.out.println(sharedVar);//输出小于20000的值
}
}
这个例子中,开启了两个线程,每个线程对共享变量sharedVar的值增加10000次,
期望的输出为20000,但结果输出不定,是一个小于20000的数字。
因为执行过程可能为线程1取值为1->线程1计算结果为2->线程2取值为1->线程2计算结果为2->线程1存值为2->线程2存值为2。即线程2在线程1的计算结果写之前又读了这个值,造成结果错误。
- 线程安全的定义
- 《Java并发编程实战(Java Concurrency In Practice)》中的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下 的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对 象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”
- 通俗的理解:在
多线程
情况下,对共享内存
的使用,不会因为不同线程的访问和修改
而发生不期望的情况。
- 线程安全的三大特性
Java内存模型是围绕着在并发过程中如何处理原子性
、可见性
和有序性
这三个特征来建立的,这三个特征是线程安全的必备三要素,JMM提供了一系列的途径来满足这三个特征。
- 原子性:
一个
或多个
操作,要么全部连续执行
且不会被任何元素中断,要么就不执行。
对于单个操作
来说,JMM保证基本数据类型的单个操作(访问、读写)都是具备原子性的(long和double是例外)。
对于多个操作
来说,JMM提供锁
机制来确保原子性。 - 可见性:当一个线程
修改
了共享变量的值时,其他线程能够立即
得知这个修改。
JMM提供volatile
、final
、锁来保证可见性。 - 有序性:程序执行的顺序应当按照代码的先后顺序执行。如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程, 所有的操作都是无序的。(本线程内部的代码,可以重排列,不会对本线程的结果造成影响,但可能对其他线程造成不期望的影响)。JMM提供
volatile
禁止指令重排,锁
保证持有同一个锁的两个同步块只能串行地进入。
3. Synchronized
- synchronized为
指定对象加锁
,如果一个线程需要进入一个同步方法或同步代码块,需要先获取锁,一个对象只有一个锁 - 使用synchronized修饰方法
- 修饰实例方法
public synchronized void f(){}
此时加锁的对象是类的实例对象
,等价于
public void f(){
synchronized(this){}
}
其他线程需要进入这样的同步代码块或该类的同步实例方法时,需要先获取类的实例对象
的锁。如果不需要进入同步代码块或同步实例方法就不需要获取锁。
2. 修饰类方法
class A{
public static synchronized void f(){}
}
此时加锁的对象是类的class对象
,等价于
class A{
public static void f(){
synchronized(A.class){}
}
}
其他线程需要进入这样的同步代码块或该类的静态同步实例方法时,需要先获取类的class对象
的锁。
- 使用synchronized为任意对象加锁
class A{
Object o = new Object();
public void f(){
synchronized(o){}
}
}
其他线程需要进入该代码块时,需要先获取o对象
的锁。
- 总之:
一个对象只有一把锁,对于同步代码块,线程必须获取锁才能进入。
- synchronized具有
可重入性
,已经获取锁的线程,可以直接再次
进入同步代码块。
- Synchronized可以保证线程安全的
原子性
、可见性
、有序性
- 原子性:被synchronized修饰的代码块包含多个操作,
因为有锁存在,这些操作不会被其他线程打断
,因此满足原子性。 - 可见性:如果对一个共享变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行
load或assign操作初始化变量的值
。对一个变量执行unlock操作之前,必须先把此变量同步回主内存
(执行store和write操作)。 - 有序性:需要同一个锁的多个线程进入同步代码块时,是
顺序进入的
,同步代码块内部可能会进行指令重排列
,但不会对其他线程的结果造成影响
。
4. Volatile
- volatile可以满足轻量级的线程安全,在一定程度上,低于锁的开销。
- volatile保证
有序性
、可见性
,不保证原子性
- 有序性:volatile可以禁止指令重排,被volatile修饰的变量,在代码块中,会保证执行的次序。
语句1
语句2
含有volatile变量的语句
语句3
语句4
其中含有volatile变量的语句一定在语句1,2之后执行,先于语句3,4执行,但语句1,2,语句3,4可能会被重排列。
例:单例模式的双重校验锁使用到了volatile的禁止指令重排功能
Java23种设计模式总结
- 可见性:当一个变量被volatile修饰时,那么对它的修改会
立刻
刷新到主存,当其它线程需要读取该变量时,会去内存中读取新值,而普通变量则不能保证这一点。volatile保证可见性的原理是内存屏障
(当CPU写数据时,如果发现一个变量在其他CPU中存有副本,那么会发出信号通知其他CPU将该副本对应的缓存行置为无效状态,当其他CPU读取到变量副本的时候,会发现缓存行是无效的,然后它会从主存重新读取。)
public class TestVolatile {
public int i = 0;
Runnable setRunnable = () -> i = 1;
Runnable getRunnable = () -> {
while (i!=1) {
}
};
public static void main(String[] args) {
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(40, 40, 1, TimeUnit.SECONDS,new ArrayBlockingQueue<>(40));
for(int i = 0 ; i < 20 ;i++){
TestVolatile testVolatile = new TestVolatile();
threadPool.execute(testVolatile.getRunnable);
threadPool.execute(testVolatile.setRunnable);
}
threadPool.shutdown();
while (!threadPool.isTerminated()) {
System.out.println(threadPool.getActiveCount());
}
}
}
这个例子证明了volatile的可见性。每个TestVolatile的实例包含两个任务(设置i的值为1,无限循环检测i值是否为1),期望情况下,setRunnable修改i的值为1后,getRunnable就检测到i的值为1停止循环,但在并发情况下,getRunnable可能不会立即
检测到i的值发生变化。
这里生成了20个对象,40个任务(线程),期望情况下,40个线程全部执行完毕,但在i的值不用volatile修饰时,总会有线程进入死循环。
如果加上volatile修饰
,就可以满足期望的情况,40个线程全部执行完成。
(另外,如果线程数较少,可能不会出现线程进入死循环的情况。)