一、概念
- 并行与并发:1个核对1个线程是并行执行,1个核对多个线程是并发执行。
- 线程安全:并发带来竞争,竞争的结果会让多个线程同时写某个共享变量时出现数据错误问题,该问题即线程安全问题。
- 线程同步:解决线程安全问题的方式方法。
二、JMM与happens-before规则
2.1 JMM抽象结构模型:
我们知道CPU执行指令的速度是远远快于内存读写速度的,如果任何时候对数据的操作都要通过和内存的交互来进行,会大大降低指令执行的速度。因此在CPU里面就有了高速缓存。因此,线程在执行的时候,不会直接读取主内存,而是会在每个CPU核的高速缓存里面读取数据,每次CPU在执行线程的时候,会将需要的数据从主内存读取到高速缓存中。JMM就从抽象层次定义了这种方式,并且JMM决定了一个线程对共享变量的写入何时对其他线程是可见的。
JMM抽象结构模型因为jvm会对代码进行编译优化,指令会出现重排序的情况,为了避免编译优化对并发编程安全性的影响,需要happens-before规则定义一些禁止编译优化的场景,保证并发编程的正确性。
2.2 happens-before6条规则如下:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
三、三大性质:
- 原子性:操作是一个整体,不可分割。这是批处理与回滚的概念,保证数据操作的线程安全。
- 可见性:当多个线程访问同一个变量时,一个线程修改了变量的值,其他的线程能立即看到修改的值。这是保证数据在内存同步的线程安全。
- 有序性:CPU不按程序规定的顺序执行指令,但是最终执行结果与程序顺序执行的结果一致。这是站在编译器与处理器是否做重排序的角度保证线程安全。
通过三大性质来保证线程安全。
四、同步方案
4.1 volatile
volatile是Java关键字,仅保证可见性、有序性,不保证原子性。
- 可见性:变量如果被声明为volatile,就是告诉JVM,这个变量是不稳定的,每次读变量都从内存中读,跳过 CPU cache 这一步。一般说来,多任务环境下,各任务间共享的变量都应该加volatile修饰符。
- 有序性:有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障,即指令重排序时不能把后面的指令重排序到内存屏障之前的位置,最终实现禁止指令重排序优化。
-
原子性:不保证原子性,它需要配合CAS(compare and swap),来保证原子性。
使用场景:大部分场景是对非原子性的操作保证其有序性。
举例:
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){
}
public static Singleton getInstance() {
if (singleton== null) {
//首先通过synchronized来保证同一时刻只有一个线程能操作
synchronized (Singleton.class) {
//拿到锁之后还需要再判一次空,可能之前持锁线程已经创建了实例了
if (singleton== null) {
//singleton是引用类型,不具备原子性,因此可能发生指令重排。
//正常情况:实例化对象的流程:
//1.在堆上开辟空间
//2.属性初始化
//3.将栈上空间指向堆。
//正常情况是1->2->3 ,重排可能为1->3->2
//由于3步骤先执行,singleton已经不为空了,这时候其他进程访问getInstance直接return 对象,但是当前初始化并没有完成,出现问题。因此可以使用volatile来保证有序性,防止指令重排。
singleton= new Singleton();
}
}
}
return singleton;
}
}
4.1 JUC类库
4.1.1 java.util.concurrent.atomic 下的AtomicInteger、AtomicBoolean、AtomicLong等。保证类的原子性
底层原理:
CAS操作(又称为无锁操作)是一种保证原子性的机制,它不是通过加锁阻塞其他线程操作,而是通过比较交换来鉴别线程是否出现冲突,出现冲突就重复当前操作直到没有冲突为止。有竞争就会自旋。而CAS修改值时的原子性问题通过汇编lock cmpxchg 锁总线来保证,即从硬件层面保证原子性。
交换过程:
三个值:内存地址存放的实际值 、预期旧值 、更新的新值
如果实际值=旧值,证明没有更新过,直接将新值赋给实际值。
如果实际值≠旧值,证明被修改,不能将新值赋给实际值,直接返回之前的实际值。
当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。CAS的实现需要硬件指令集的支撑,在JDK1.5后虚拟机才可以使用处理器提供的CMPXCHG指令实现。
CAS的问题:
-
ABA问题:旧值A变为了成B,然后再变成A,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。ABA问题加标签解决,两个A以不同标签来区分。
-
自旋时间长问题:CAS有竞争会自旋,如果长时间得不到执行,会造成明显cpu消耗。如果JVM能支持处理器提供的pause指令,那么在效率上会有一定的提升。
只能保证一个共享变量的原子操作:对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。但是可以将多个变量整合为对象,然后将这个对象做CAS操作就可以保证其原子性。atomic中提供了AtomicReference来保证引用对象之间的原子性。
4.1.2 java.util.concurrent.locks.ReentrantLock
底层原理:
AQS: 在cas基础上包了一个fifo的双向链表来保存尝试获取锁的线程。
公平:进来的线程都加入队尾,串行获取锁。
非公平:刚进来的线程有机会插队获取锁,一旦获取不到则还是加入队尾。
通过state来表示:没有线程持锁、有线程持锁、重入多少次。
另外RentrantLock可中断,能避免死锁。
4.2 synchronized
-
可见性:
JMM关于synchronized的两条规定:
1)线程解锁前,必须把共享变量的最新值刷新到主内存中
2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
(注意:加锁与解锁需要是同一把锁)
通过以上两点,让synchronized能够实现可见性。 -
有序性:synchronized无法禁止重排序,但是他保证了多线程串行执行,同一时刻只有一个线程执行。但是编译器和处理器无论如何重排序都要遵循as-if-serial原则,这个原则保证不管怎么重排序,单线程程序的执行结果都不能被改变。因此synchronized只要保证了串行的单线程执行,那么相当于也保证了有序性。
-
原子性:synchronized与字节码指令:monitorenter和monitorexit对应,monitor机制保证了synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放前,无法被其他线程访问到。
synchronized锁升级:
锁升级过程:偏向锁->自旋锁->重量级锁
-
偏向锁:当一个已经持有锁的线程再次请求锁时无需再做任何同步操作。在针对几乎是同一个线程反复请求锁而没有锁竞争的场景下,节省大量有关锁申请的操作,性能非常高。
-
自旋锁:当线程暂时无法获得锁时,系统乐观的认为该线程将在不久的将来得到锁,虚拟机会让该线程自旋几次(虚拟机会设一个自旋最多次数,比如10次),即循环判断是否可以获取锁了。自旋会占用CPU资源,但是轻量的自旋还是优于锁申请的相关操作,因此轻微的竞争或者段时间持锁的场景下,自旋锁性能相对比较好。
-
重量级锁:如果自旋超过最大限制,证明锁竞争严重,则会升级为重量级锁,等锁的线程会被挂起,等待释放锁的线程去唤醒。这是在锁竞争严重的场景下的策略,减少自旋的CPU消耗。
synchronized几种锁应用的区别:
对象锁:作用于对象实例。多个线程调用同一个对象的同步方法会阻塞,调用不同对象的同步方法不会阻塞。
锁方法:
public synchronized void func(){}
锁代码块:
public void func(){
synchronized(object){
...
}
}
类锁:作用于类。一个class不论被实例化多少次,其中的静态方法和静态变量在内存中都只有一份拷贝,因此多线程调用此类同步方法均阻塞。
锁方法:
public static synchronized void func(){}
锁代码块:
public void func(){
synchronized(XXX.class){
...
}
}
synchronized 与ReentrantLock区别:
支持的功能:
加锁方式 | 可重入 | 公平非公平 | 可中断 | 互斥 |
---|---|---|---|---|
synchronized | 是 | 非公平 | 否 | 是 |
ReenTrantLock | 是 | 可以在构造函数指定,默认非公平 | 是 | 是 |
注:
- 可重入锁:可以重新进入的锁,即允许同一个线程多次获取同一把锁,递归操作不会出现死锁。
- 公平锁:多个线程等待同一个锁时,必须按照申请锁的时间顺序获得锁。
- 非公平锁:按一定策略来让某些线程优先获取锁,在大多数情况下,非公平锁的吞吐量比公平锁的大,这个我自己没考证过。
- 可中断锁:可以响应中断的锁,不想一直等可以中断,避免死锁。
- 互斥锁:互相排斥的锁,也就是表明锁只能被一个线程拥有。
区别总结:
- 原理区别:ReentrantLock是类,synchronzied是关键字;Lock锁是通过代码实现的,synchronzied是托管给jvm执行的;
- 用法区别:synchronized既可以加在方法上,也可以加载特定的代码块上,括号中表示需要锁的对象。而Lock需要显示地指定起始位置和终止位置。
- 功能区别:ReentrantLock支持公平非公平设置,和可中断。前者设置在竞争激烈的情况下,性能会优于synchronized,后者ReentrantLock可以让等待锁的线程响应中断而synchronized不行,
参考:
https://www.jianshu.com/p/d53bf830fa09
https://www.jianshu.com/p/d52fea0d6ba5
https://www.zhihu.com/question/41016480?sort=created
网友评论