一、volatile
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
-
禁止进行指令重排序。
-
保证不同线程对这个变量进行操作时的可见性。即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
那么原理是什么呢?
在《深入理解JAVA虚拟机》中有如下描述:
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”。
lock前缀指令实际上相当于一个内存屏障 ,内存屏障会提供3个功能:
-
它确保指令重排序时,不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
-
它会强制将对缓存的修改操作立即写入主存;
-
如果是写操作,它会导致其他CPU中对应的缓存行无效。
问题:第2步很好理解,第3步如何做到呢?
因为其他CPU的缓存遵守了
缓存一致性协议。缓存一致性协议(MESI协议):
在早期的CPU中,是通过在总线加LOCK锁的方式实现的,但这种方式开销太大,所以Intel开发了缓存一致性协议,也就是MESI协议。
设计思路:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,那么他会发出信号通知其他CPU将该变量的缓存行设置为无效状态。当其他CPU使用这个变量时,首先会去嗅探是否有对该变量更改的信号,当发现这个变量的缓存行已经无效时,会从新从内存中读取这个变量。
这就保证了一个volatile在并发编程中,其值在内部缓存、系统内存和其他处理器的缓存中是保持一致的,是可见的。
volatile的不足
使用volatile关键字,可以保证可见性,但是不能保证原子操作。
比如两个线程要同时对 count 进行 count++ 操作,此时查看编译后的字节码可以看到,这个过程分为3个步骤:
1、从内存读取count
2、count + 1
3、将count的新值写回
关键点来了,volatile只能保证线程在读取这一步拿到的值是最新的,但当该线程执行到下面几个步骤时,这期间可能就有其它线程把count的值修改了,最终导致旧值把真正的新值覆盖。
所以,并发编程中,只靠volatile修饰共享变量是不可靠的,最终还是要通过对关键方法加锁来保证线程安全。
二、锁
多线程访问共享资源的时候,避免不了资源竞争而导致数据错乱的问题,所以我们通常为了解决这一问题,都会在访问共享资源之前加锁。
最常用的就是互斥锁,当然还有很多种不同的锁,比如自旋锁、读写锁、乐观锁等,不同种类的锁自然适用于不同的场景。
如果选择了错误的锁,那么在一些高并发的场景下,可能会降低系统的性能,这样用户体验就会非常差了。
所以,为了选择合适的锁,我们不仅需要清楚知道加锁的成本开销有多大,还需要分析业务场景中访问的共享资源的方式,再来还要考虑并发访问共享资源时的冲突概率。
对症下药,才能减少锁对高并发性能的影响。
2.1、互斥锁和自旋锁
最底层的两种就是会「互斥锁和自旋锁」,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基,所以我们必须清楚它俩之间的区别和应用。
-
互斥锁加锁失败后,线程会释放 CPU ,给其他线程。
1、互斥锁是一种「独占锁」,比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
2、对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的。会有两次线程上下文切换的成本。当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
3、线程的上下文切换的是什么?当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。
4、上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
-
自旋锁加锁失败后,线程会忙等待处于循环状态,直到它拿到锁。
自旋锁是通过 CPU 提供的
CAS函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
总结:如果你能确定被锁住的代码执行时间很短,甚至少于互斥锁上下文切换的时间,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
2.2、读写锁
读写锁从字面意思我们也可以知道,它由「读锁」和「写锁」两部分构成,如果只读取共享资源用「读锁」加锁,如果要修改共享资源则用「写锁」加锁。
所以,读写锁适用于能明确区分读操作和写操作的场景。
- 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁。多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率。
- 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁。
2.3、悲观锁和乐观锁
前面提到的互斥锁、自旋锁、读写锁,都属于悲观锁。
悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。
乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程。
这里举一个场景例子:在线文档。
我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。
那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。
怎么样才算发生冲突?
这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交改动,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。
服务端要怎么验证是否冲突了呢?通常方案如下:
- 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
- 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前修改后的版本号进行比较,如果版本号一致则修改成功,否则提交失败。
实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。
总结:乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
2.4、可重入锁
看下面这段代码就明白了:
class MyClass {
public synchronized void method1() {
method2();
}
public synchronized void method2() {
}
}
上述代码中的两个方法method1和method2都用synchronized修饰了,假如某一时刻,线程A执行到了method1,此时线程A获取了这个对象的锁,而由于method2也是synchronized方法,假如synchronized不具备可重入性,此时线程A需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。
而由于synchronized和Lock都具备可重入性,所以不会发生上述现象。
2.5、公平锁和非公平锁
公平锁:以请求锁的顺序来获取锁。比如同时有多个线程在等待一个锁,当这个锁被释放时,等待时间最久的线程(最先请求的线程)会获得该所,这种就是公平锁。
非公平锁:无法保证锁的获取是按照请求锁的顺序进行的。
ReentrantLock有两个构造器,一个是无参构造,一个是传入fair参数的。
fair代表锁的公平策略,true:表示构造一个公平锁;false:表示构造一个非公平锁(默认)。
三、死锁
死锁案例
public class DeadLock2 {
static Object objA = new Object();
static Object objB = new Object();
public static class MyThread implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + ",run prepare");
synchronized (objA) {// obj锁
System.out.println(Thread.currentThread().getName() + ",拿到objA锁");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (objB) {
System.out.println(Thread.currentThread().getName() + ",拿到objA锁");
}
}
}
}
public static class MyThread2 implements Runnable {
public void run() {
System.out.println(Thread.currentThread().getName() + "run prepare");
synchronized (objB) {// obj锁
System.out.println(Thread.currentThread().getName() + ",拿到 objB锁");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (objA) {
System.out.println(Thread.currentThread().getName() + ",拿到 objA锁");
}
}
}
}
public static void main(String[] args) {
new Thread(new MyThread()).start();
new Thread(new MyThread2()).start();
}
}
MyThread拿到objA锁,MyThread2拿到objB锁,等到MyThread想去拿objB锁时,objB锁被MyThread2拿着,MyThread2想去拿objA锁时,objA锁被MyThread拿着,两边各不相让,造成了死锁。
死锁产生的条件
- 互斥:某共享资源一次只允许一个线程占有。
- 占有且等待:一个线程本身占有资源(一种或多种),同时还有资源未得到满足,则不会释放已有的资源。(白话:我的是我的,我得不到你的,你也别想得到我的)
- 不可抢占:其他线程不能抢占已被别的线程占有的资源。(白话:我的是我的,谁也别想拿去)
- 循环等待:线程A等待线程B的资源,线程B等待线程A的资源,这就是循环等待。(白话:A说:你不给我,我也不给你;B说:你不给我,我也不给你)
死锁怎么解决
只要破坏一个死锁产生的条件就不会发生死锁。
设计思想:若重新设计一把互斥锁去解决这个问题,怎么搞呢?
-
能响应中断 使用synchronized持有 锁X 后,若尝试获取 锁Y 失败,则线程进入阻塞,一旦死锁,就再无机会唤醒阻塞线程。但若阻塞态的线程能够响应中断信号,即当给阻塞线程发送中断信号时,能唤醒它,那它就有机会释放曾经持有的 锁X。
-
非阻塞地获取锁 如果尝试获取锁失败,并不进入阻塞状态,而是直接返回,那这个线程也有机会释放曾经持有的锁
-
支持超时(在非阻塞地获取锁上加个时间) 若线程在一段时间内,都没有获取到锁,不是进入阻塞态,而是返回一个错误,则该线程也有机会释放曾经持有的锁
这就是Lock。
四、synchronized
4.1、作用
-
原子性:确保线程互斥的访问同步代码;
-
可见性:保证共享变量的修改能够及时可见,其实是通过JVM内存模型中的 “对一个变量unlock操作之前,必须要同步到主内存中;如果对一个变量进行lock操作,则将会清空工作内存中此变量的值,在执行引擎使用此变量前,需要重新从主内存中load操作或assign操作初始化变量值” 来保证的;
-
有序性:有效解 决重排序问题,即 “一个unlock操作先行发生(happen-before)于后面对同一个锁的lock操作”。
从语法上讲,Synchronized可以把任何一个非null对象作为"锁",在HotSpot JVM实现中,锁有个专门的名字:对象监视器(Object Monitor)。
4.2、使用
4.3、原理
数据同步需要依赖锁,那锁的同步又依赖谁?
-
synchronized给出的答案是在软件层面依赖JVM。
-
Lock给出的答案是在硬件层面依赖特殊的CPU指令。
当一个线程访问同步代码块时,首先是需要得到锁才能执行同步代码,当退出或者抛出异常时必须要释放锁,那么它是如何来实现这个机制的呢?我们先看一段简单的代码:
package com.paddx.test.concurrent;
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
查看反编译后结果:
- 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 的所有权。
monitorexit指令出现了两次,第1次为同步正常退出释放锁;第2次为发生异步退出释放锁;
通过上面两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。
再来看一下同步方法:
package com.paddx.test.concurrent;
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
查看反编译后结果:
从编译的结果来看,方法的同步并没有通过指令 monitorenter 和 monitorexit 来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了 ACC_SYNCHRONIZED 标示符。JVM就是根据该标示符来实现方法的同步的:
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
两种同步方式本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。两个指令的执行是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。