Q:你知道线程中的变量和内存是怎样交互的吗?你知道为什么会产生线程安全吗?知道怎么保证线程安全吗?并发三原则你知道吗?volatile、cas听烦了,但是你知道真正的原理吗?
A:看完这篇文章你就全知道啦!
一、了解java线程内存模型
线程(Thread)、工作内存(Cache)、主内存(Memory)三者之间的交互关系图:
image.png总结一下就是,一个线程对一个共享变量的操作大致分为三步
1.当前线程首先从主内存拷贝共享变量到自己的工作内存
2然后对工作内存里的变量进行处理
3.处理完后更新变量值到主内存
二、 并发三原则
1)可见性
可见性 - 是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果,另一个线程马上就能看到。简单来说,线程的私有内存中对象副本和主内存对象的数据之间就是可见性问题,线程不把他私有内存中的对象副本协会到主内存中,那么对于其他想操作这个对象的线程来说就是"不可见的" ,最新数据不可见,所以此时其他线程可以获取该对象的旧数据
2)原子性
原子性 - 对基本类型变量的读取和赋值操作是原子性操作,即这些操作是不可中断的,要么执行完毕,要么就不执行。在多线程中,原子性是线程不安全的,其实和可见性有关联,线程在计算数据时会把在自己的工作区域 copy 一份数据的副本,然后计算的是这个数据的副本,最后再把数据学回内存中,这个过程里要是有别人线程同时操作同一个数据,那么我们的计算结果就是不正确了,就像打电话串线一样,结果肯定不对。
x =3;
y =4;
z = x+y;
x++;
上面第三行就包括了多个操作,1是先读取x的值,2读取y的值,3计算x+y的值,4把z的值写回内存。
一般一个语句含有多个操作该语句就不是原子性的操作,只有最简单的读取和赋值才是原子性的操作
3)有序性
有序性 - 即程序执行的顺序按照代码的先后顺序执行,上面讲了因为原子性问题,绝大部分操作都是可以再分的,分成多个操作,这其中有对数据的读,改,写等,这些操作速度不一,JVM 为了提高效率,在保证结果相同的前提下,有计划的多这么多操作分组,在执行需要等待的操作中,穿插执行其他执行速度块的操作,这叫指令重排序。
指令重排序指的是在 保证程序最终执行结果和代码顺序执行的结果一致的前提下,改变语句执行的顺序来优化输入代码,提高程序运行效率。
重排序在单线程中没啥问题,咱们等着执行结果呗,反正重排序保证结果正确。但是在多线程环境下是存在并发的,你这里对某一个对象的执行重排序了,但是不是瞬间完成的,这时另外的线程可以操作这个对象,那么你这个重排序后的执行可能造成此时对象数据的不正确,会对其他线程使用这个对象产生影响
以下面的举个例子:
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
定义了一个整形和Boolean型变量,并通过语句1和语句2对这两个变量赋值,
但是JVM在执行这段代码的时候并不保证语句1在语句2之前执行,也就是说可能会发生 指令重排序。
再来个例子:
//线程1:
context = loadContext(); //语句1
inited = true; //语句2
//线程2:
while (!inited) {
sleep()
}
doSomethingWithConfig(context);
对于线程1来说,语句1和语句2没有依赖关系,因此有可能会发生指令重排序的情况。
但是对于线程2来说,语句2在语句1之前执行,那么就会导致进入doSomethingWithConfig函数的时候context没有初始化,就会出现异常
Java 提供了3个关键字 volatile、synchronized 和 final 来实现并发3原则
- final - 最好理解,一切都是不可变的,所以不在乎有多少个线程同时操作这个资源
- synchronized - 之前的文章介绍了,synchronized 保证了有序性,你想 synchronized 使用一把锁锁住了资源,那别人想用只能等着,即便你再怎么重排序,我也能保证执行效果
- volatile - 下面重点介绍
三、产生线程安全的原因
线程的工作线程是cpu的寄存器和高速缓存的抽象描述:现在的计算机,cpu在计算的时候,并不总是从内存读取数据,它的数据读取顺序优先级是: 寄存器-高速缓存-内存 。线程耗费的是CPU,线程计算的时候,原始的数据来自内存,在计算过程中,有些数据可能被频繁读取,这些数据被存储在寄存器和高速缓存中,当线程计算完后,这些缓存的数据在适当的时候应该写回内存。当多个线程同时读写某个内存数据时,就会产生多线程并发问题,涉及到三个特 性:原子性,有序性,可见性。 支持多线程的平台都会面临 这种问题,运行在多线程平台上支持多线程的语言应该提供解决该问题的方案。
JVM是一个虚拟的计算机,它也会面临多线程并发问题,java程序运行在java虚拟机平台上,java程序员不可能直接去控制底层线程对寄存器高速缓存内存之间的同步,那么java从语法层面,应该给开发人员提供一种解决方案,这个方案就是诸如 synchronized, volatile,锁机制(如同步块,就绪队 列,阻塞队列)等等。这些方案只是语法层面的,但我们要从本质上去理解它;
每个线程都有自己的执行空间(即工作内存),线程执行的时候用到某变量,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作:读取,修改,赋值等,这些均在工作内存完成,操作完成后再将变量写回主内存;
各个线程都从主内存中获取数据,线程之间数据是不可见的;打个比方:主内存变量A原始值为1,线程1从主内存取出变量A,修改A的值为2,在线程1未将变量A写回主内存的时候,线程2拿到变量A的值仍然为1;
这便引出“可见性”的概念:当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量的副本值,那么其他线程应该能够看到这个被修改后的值,这就是多线程的可见性问题。
普通变量情况:如线程A修改了一个普通变量的值,然后向主内存进行写回,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见;
四、如何保证线程安全?
线程安全的前提是该变量是否被多个线程访问, 保证对象的线程安全性需要使用同步来协调对其可变状态的访问;若是做不到这一点,就会导致脏数据和其他不可预期的后果。无论何时,只要有多于一个的线程访问给定的状态变量,而且其中某个线程会写入该变量,此时必须使用同步来协调线程对该变量的访问。Java中首要的同步机制是synchronized关键字,它提供了独占锁。除此之外,术语“同步”还包括volatile变量,显示锁和原子变量的使用。
五、保证线程同步的几种方法
1) volatile
volatile要求程序对变量的每次修改,都写回主内存,这样便对其它线程可见,解决了可见性的问题,但是不能保证数据的一致性。;
2) synchronized同步锁
同步锁:每个JAVA对象都有且只有一个同步锁,在任何时刻,最多只允许一个线程拥有这把锁。
当一个线程试图访问带有synchronized(this)标记的代码块时,必须获得 this关键字引用的对象的锁,在以下的两种情况下,本线程有着不同的命运。
3) Lock、ReentrantLock
在JDK1.5中新增ReentrantLock类,效果类似于使用synchronized关键字实现线程间同步互斥,并且在扩展功能上也更加强大,比如具有嗅探锁定、多路分支通知等功能。
@Override
public void run() {
while(true){
Lock lock = new ReentrantLock();
try {
lock.lock();
Thread.sleep(new Random().nextInt(3000));
String data = readData();
System.out.print("读取数据: " + data);
} catch (InterruptedException e) {
e.printStackTrace();
}finally{
lock.unlock();
}
}
}
4)CAS
Compare And Swap,即 比较 并 交换,是一种解决并发操作的乐观锁
synchronized锁住的代码块:同一时刻只能由一个线程访问,属于悲观锁
见下面详解
六、synchronized
6.1 synchronized原理
1、 假如这个锁已经被其它的线程占用,JVM就会把这个线程放到本对象的锁池中。本线程进入阻塞状态。锁池中可能有很多的线程,等到其他的线程释放了锁,JVM就会从锁池中随机取出一个线程,使这个线程拥有锁,并且转到就绪状态。
2、 假如这个锁没有被其他线程占用,本线程会获得这把锁,开始执行同步代码块。
(一般情况下在执行同步代码块时不会释放同步锁,但也有特殊情况会释放对象锁
如在执行同步代码块时,遇到异常而导致线程终止,锁会被释放;在执行代码块时,执行了锁所属对象的wait()方法,这个线程会释放对象锁,进入对象的等待池中)
synchronized关键字保证了数据读写一致和可见性等问题,但是他是一种阻塞的线程控制方法,在关键字使用期间,所有其他线程不能使用此变量;
synchronized在内存中的表现
1.被修饰代码块或方法会把在 Synchronized 块内使用到的变量从线程的工作内存中清除,在 Synchronized 块内使用该变量时就不会从线程的工作内存中获取了,而是直接从主内存中获取,退出 Synchronized 块,则会把 Synchronized 块内对共享变量的修改刷新到主内存。
注意:被Synchronized修饰的方法,如果不指定监视器,则默认监视器就是这个类。线程在调用方法时,会首先获取该方法的监视器,如果该监视器已经被其他线程获取,那么此线程将会阻塞。
6.2 synchronized和lock的区别
1)synchronized是关键字,是JVM层面的底层啥都帮我们做了,而Lock是一个接口,是JDK层面的有丰富的API。
2)synchronized会自动释放锁,而Lock必须手动释放锁。
3)synchronized是不可中断的,Lock可以中断也可以不中断。
4)通过Lock可以知道线程有没有拿到锁,而synchronized不能。
5)synchronized能锁住方法和代码块,而Lock只能锁住代码块。
6)Lock可以使用读锁提高多线程读效率。
7)synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
6.3 synchronized底层原理
根本:monitor对象的争夺。
(1)字节码层面
synchronized是基于进入和退出管程(Monitor)对象实现(monitorenter和monitorexit), monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块结束的位置,任何一个对象都有一个Monitor与之相关联,当一个线程持有Minitor后,它将处于锁定状态。
monitor监视器源码是C++写的,在虚拟机的ObjectMonitor.hpp文件中。
解析类文件得到字节码文件:
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class juc/Synchronized
2: dup
3: astore_1
4: monitorenter // 这里
5: aload_1
6: monitorexit // 这里
7: goto 15
10: astore_2
11: aload_1
12: monitorexit // 这里
13: aload_2
14: athrow
15: return
(2)JVM层面实现
在JVM级别是通过编译后access_flags标志位(ACC_SYNCHRONIZED)来表示是否使用了synchronized
monitor数据结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程重入次数
_object = NULL; // 存储Monitor对象
_owner = NULL; // 持有当前线程的owner
_WaitSet = NULL; // wait状态的线程列表
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ; // 单向列表
FreeNext = NULL ;
_EntryList = NULL ; // 处于等待锁状态block状态的线程列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
_previous_owner_tid = 0;
}
6.4 synchronized的优化
synchronized在JDK1.6之前是重量级锁(设计Linux中用户态和内核态的转换,大量的系统资源消耗),在JDK1.6以后对synchronized做了优化,增加了偏向锁,轻量级锁,自旋等操作,大大增加了synchronized的效率。
1)偏向锁
原因:按照之前HotSpot的设计,每次加锁/解锁都会涉及到一些CAS操作,但是实验发现大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程获得。
目的:为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径。因为轻量级锁的加锁解锁操作是需要依赖多次CAS原子操作,减少CAS操作则可以大大增加系统的执行效率
2)自旋
上面提到了Linux系统的用户态和内核态的切换很耗资源,其实就是线程的等待唤起过程,那怎么才能减少这种消耗呢?
自旋,过来的现在就不断自旋,防止线程被挂起,一旦可以获取资源,就直接尝试成功,直到超出阈值,自旋锁的默认大小是10次,-XX:PreBlockSpin可以修改。
自旋都失败了,那就升级为重量级的锁,像1.5的一样,等待唤起咯。
七、volatile
7.1 volatile 的特性: 先是非同步的 -> 保证了可见性 -> 同时也保证有序性 -> 但是不保证原子性
- 非同步 - volatile 修饰的变量不是 synchronized 的,不是同步的,同一时间是能被多个线程操作的,所以 volatile 的使用范围比较窄,多用于修饰 static 静态变量
- 保证可见性 - 好多地方都说 volatile 修饰的变量,线程直接和内存交互,不会保存副本,而实际上线程还是会保存副本,只不过 CPU 每次都会从内存中拿到最新的值,并且改变数据之后立马写回内存并通知其他改数据的备份数据改变了,看上去就像线程直接和内存交互一样
- 不保证原子性 - volatile 语义并不能保证变量的原子性。对任意单个volatile变量的读/写具有原子性,但类似于i++、i–这种复合操作不具有原子性,因为自增运算包括读取i的值、i值增加1、重新赋值3步操作,并不具备原子性
- 保证有序性 - volatile 能够屏蔽指令重排序:
当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,
且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,
也不能把volatile变量后面的语句放到其前面执行。
7.2 volatile 适用场景
禁止系统重排序的情况
只有一个线程写,多个线程读的情况
关键的标记行参数
静态单例
7.3 volatile 举例
volatile 最经典的用处就是单例了
public class SingleTon {
public static volatile SingleTon instance;
public static SingleTon getInstance() {
if (instance == null) {
synchronized (SingleTon .class) {
if (instance == null) {
instance = new SingleTon ();
}
}
}
return instance;
}
}
我们对于静态单例使用了 volatile 就能保证整个方法的执行顺序是按照我们缩写的执行。
若是我们不加 volatile ,在多线程时指令重排序,一个线程发现 instance 是 null 的就会 new 一个对象出来,此时因为指令重排序,很可能先在内存 new 一块空间然后赋值给 instance ,然后再去执行实例化对象的操作,对象实例化的操作是比较重的。这时候另一个线程进来,发现 instance 不是 null ,然后就去执行代码,但是此时 instance 实际只是有了一块内存地址,但是对象本身还没初始化,就会产生空指针的问题
7.4 volatile 优缺点
优点:volatile 的优势是同步性能开销比锁低很多,若是使用 synchronized + 锁,切换锁给不同的线程要好几毫秒,比 new 个线程对象都耗费时间多了
缺点:但是 volatile 也有很严重的问题,那就是 volatile 不能保证原子性,虽然 volatile 让内存可以同步到所有地方,但是并不能阻止多个线程同时操作同一个数据,是没法保证原子性的,所以是不能代替 synchronized + 锁,因此我们在使用 volatile 要及其小心,要思考会不会带来并发问题,一般我们见到的 volatile 应用都很少,也都很死,都是固定的几个场景使用
八、CAS
CAS 也叫无锁算法(Compare and Swap),核心思想是:当多个线程尝试使用 CAS算法同时更新同一个变量时,只有一个线程能更新变量的值,而其他线程都失败,失败的线程并不会挂起,而是被告知这次竞争中失败,并可以再次尝试。
CAS 有3个操作数,内存值 V,旧的预期值 A,要修改的新值 B,当且仅当预期值 A 和内存值 V 相同时,将内存值 V 修改为 B,否则什么都不做
是不是很晕,听不懂,来来来,看看图就懂了
image.png我们知道 JVM 模型中,对象存储在对堆内存中,堆内存是公用的;但是每个线程有自己的方法栈内存,但是这个栈内存是私有的,线程之能访问自己的栈内存,不能访问别人的。更坑爹的是 CPU 还有自己的高速缓存,因为内存的速度很慢,没法给 CPU 拿来做计算用,所以 CPU 之能另起炉灶,有一级,二级,三级缓存,这些缓存也是私有的,那么在做计算时线程和 cpu 都会吧所需的数据从堆内存 copy 一份到自己的存储空间中,完事再写回去,这也是内存的不连续特性
image.png知道了上面这点我们就可以继续 CAS 算法了,V / A / B 3个值,V 是存储在内存中数值,A 是 CPU1 从内存 copy 过来的副本,B 就 CPU1 计算后的数据,当有多个 CPU 同时基于 CAS 算法更新同一个数据,大家在计算完毕后会比较当前内存中的值 V 和我当初 copy 过来的值 A 是不是一样,一样的话说明我比别人算的快,没有抢在我头了,我可以更新数据到内存了。其他的线程计算完成时一看自己 copy 的值已经和当前内存中的值不一样了,那么本次计算结果肯定就不对了,所以放弃了计算结果,重新更新一次数据再次做计算
九、总结
本编文章主要讲的是关于多线程的并发问题,关于这方面我们需要先了解一下计算机的CPU,内存,高速缓存之间的关系。然后展开讲解java中一个线程的内存模型,接着描述并发的三原则可见性、原子性、有序性。然后引起产生线程安全的原因,有线程安全问题,我们就需要知道哪些方法可以保证线程安全。接着就介绍三种可以保证线程安全的方法。synchronized、volatile和cas三种方式。最后关于线程的并发问题就大概这样了,学习了这么多理论知识,在开发中善于实践才能算真正的掌握。
累死了累死了
感谢您的阅读,要是有收获请记得三连,别告诉我下次一定!
您的支持是我最大的动力!
网友评论