什么是JUC
Java的java.util.concurrent简称JUC,是java在5.0版本提供的并发编程工具类,提供了线程池,异步IO等,还提供了用于多线程上下文的Collection实现等。
内存可见性
什么是java内存模型(JMM)
Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互。 具体说来, JVM中存在一个主内存区(Main Memory或Java Heap Memory),对于所有线程进行共享,而每个线程又有自己的工作内存(Working Memory),工作内存中保存的是主存中某些变量的拷贝,线程对所有变量的操作并非发生在主存区,而是发生在工作内存中,而线程之间是不能直接相互访问,变量在程序中的传递,是依赖主存来完成的。
Java内存模型的两条规定
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从内存中读写
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成
什么是内存可见性(Memory Visibility)
在多线程情况下,读和写发生在不同的线程中,而读线程未能及时的读到写线程写入的最新的值
什么是CPU的高速缓存
由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲,将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存中同步到内存中
接下来看下CPU内存结构
加入CPU高速缓存后,CPU的执行流程如下
- 将计算需要的数据缓存在CPU高速缓存中
- CPU计算时,直接从高速缓存中获取数据进行计算
- 计算完成后将数据写回缓存
- 将数据写回主内存
为什么会产生缓存一致性问题(2种解释)
第一种
一台计算机最核心的组件是CPU,内存,以及I/O设备,这三者在处理速度上有很大的差异,但最终整体的计算效率取决于最慢的那个设备,为了平衡三者的速度差异,最大化的利用CPU的性能,无论是硬件,操作协同还是编译器都做了很多的优化:
- CPU增加了高速缓存
- 操作系统增加了进程、线程,通过时间片切换最大化的提升CPU的性能
- 编译器的之灵优化,更合理的去利用CPU的高速缓存
第二种(推荐) 由于CPU的高速发展,CPU的处理速度和读写内存的速度脱节,所以出现了存在于内存和处理之间的高速缓存,每一个核都会去维护自己的高速缓存,而每个核的高速缓存是互相不可见的,进而产生了缓存一致性问题。
怎么解决缓存一致性问题
- 总线锁
- 总线锁是用来锁住总线的,当一个CPU执行一个线程去访问数据操作的时候,它会向总线上发送一个LOCK信号,此时其他的线程想要去请求主内存的时候,就会被阻塞,这样该处理器核心就可以独享这个共享内存。也可以这样理解,总线锁通过把内存和CPU之间的通信锁住,把并行化的操作变成串行,这会导致严重的性能问题,所以,随着技术的发展,就出现了缓存锁。
- 缓存锁
- 某块CPU核对缓存中数据进行操作了之后,就会通知其他CPU核放弃储存在它们内部的缓存,或者从主内存中重新读取。
MESI协议
处理器上有一套完整的协议,来保证缓存的一致性,比较经典的应该就是MESI协议了,其实现方法是在CPU缓存中保存一个标记位,以此来标记四种状态。另外,每个Core的Cache控制器不仅知道自己的读写操作,也监听其它Cache的读写操作,就是嗅探(snooping)协议。
- M:被修改的。处于这一状态的数据,只在本CPU中有缓存数据,而其他CPU中没有。同时其状态相对于内存中的值来说,是已经被修改的,且没有更新到内存中。
- E:独占的。处于这一状态的数据,只有在本CPU中有缓存,且其数据没有修改,即与内存中一致。
- S:共享的。处于这一状态的数据在多个CPU中都有缓存,且与内存一致。
- I:无效的。本CPU中的这份缓存已经无效。
怎么实现内存可见性
1. 使用volatile关键字
指令重排序
代码的书写顺序和执行顺序不一致,重排序是编译器或者处理器为了提高程序性能所做的优化
volatile关键字的作用
- 保证内存可见性,但不保证操作的原子性
- 防止之灵重拍
一句话总结volatile关键字如何保证可见性的
被volatile关键字修饰的变量,在每个写操作之后,都会加入一条store内存屏障命令,此命令强制工作内存将此变量的最新值保存至主内存;在每个读操作之前,都会加入一条load内存屏障命令,此命令强制工作内存从主内存中加载此变量的最新值至工作内存
什么时候能使用volatile
- 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
- 该变量没有包含在具有其他变量的不变式中
2. synchronized关键字
实现原理: JVM 是通过进入、退出 对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的 互斥锁(Mutex Lock) 实现
具体实现是在编译之后在同步方法调用前加入一个monitor.enter指令,在退出方法和异常处插入monitor.exit的指令
对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit之后才能尝试继续获取锁
3. volatile和synchronized的区别
- volatile不会进行加锁操作:volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制
- volatile变量作用类似于同步变量读写操作:从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
- volatile不如synchronized安全:在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些
- volatile无法同时保证内存可见性和原则性:加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”
先看下synchronized 修饰类
public class Demo {
public static void main(String[] args) {
synchronized (Demo.class) {
System.out.println("synchronized demo...");
}
}
}
编译代码
javac Demo.java
通过javap查看编译后的代码
javap -c Demo.class
synchronized 修饰方法
在synchronized修饰方法时是添加ACC_SYNCHRONIZED标识,该标识指明了该方法是一个同步方法,JVM通过该ACC_SYNCHRONIZED访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用
synchronized 特点
- 保证原子性,可见性和有序性
- 可见性说明:释放锁时,所有写入都会写回内存,获得锁后,所有读取都会从内存读取最新数据
- 可重入性
- 同一个线程在获得锁后,其他需同样锁的代码可直接调用
- 原理:1. 记录锁的持有线程和持有数量,2. 调用synchronized代码时检查对象是否已经被锁,是则检查是否被当前线程锁定,是则计数加一,不是则进入等待队列,3. 施放时计数减1,直到减为0则施放锁
- 重量级
- 底层是通过一个监视器对象(monitor)完成
- 监视器锁的本质是依赖于底层操作系统的互斥锁(Mutex Lock)实现,操作系统实现线程切换需从用户态转到内核态
- 上述切换过程较长,所以synchronized效率低,重量级
4. Synchronized 优化
从synchronized的特点中可以看到它是一种重量级锁,会涉及到操作系统状态的切换影响效率,所以JDK1.6中对synchronized进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁和轻量锁。
偏向锁
引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令(由于一旦出现多线程竞争的情况就必须撤销偏向锁,所以偏向锁的撤销操作的性能损耗必须小于节省下来的CAS原子指令的性能消耗)。
偏向锁的获取过程
- 访问Mark Word中偏向锁的标识是否设置成“1”,锁标志位是否为“01”——确认为可偏向状态。
- 如果为可偏向状态,判断线程ID是否指向当前线程,如果是进入步骤(5),否则进入步骤(3)。
- 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word中线程ID设置为当前线程ID,然后执行(5);如果竞争失败,执行(4)。
- 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。
- 执行同步代码。
偏向锁的释放
偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。
轻量锁 轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。
轻量级锁的加锁过程
- 在代码进入同步块时,如果同步对象锁状态为无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。
- 拷贝对象头中的Mark Word复制到锁记录中。
- 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock Record里的owner指针指向object mark word。如果更新成功,则执行步骤(3),否则执行步骤(4)。
- 如果这个更新动作成功,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态。
- 如果这个更新操作失败,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程。
轻量级锁的解锁过程
- 通过CAS操作尝试把线程中复制的Displaced Mark Word对象替换当前的Mark Word。
- 如果替换成功,整个同步过程完成。
- 如果替换失败,说明有其他线程尝试过获取该锁(此时锁已膨胀),那就要在释放锁的同时,唤醒被挂起的线程。
其他优化 适应性自旋:在使用CAS时,如果操作失败,CAS会自旋再次尝试。由于自旋是需要消耗CPU资源的,所以如果长期自旋就白白浪费了CPU。JDK1.6 加入了适应性自旋,即如果某个锁自旋很少成功获得,那么下一次就会减少自旋。
通过--XX:+UseSpinning参数来开启自旋(JDK1.6之前默认关闭自旋)。 通过--XX:PreBlockSpin修改自旋次数,默认值是10次。
锁消除:锁消除指的就是虚拟机即使编译器在运行时,如果检测到那些共享数据不可能存在竞争,那么就执行锁消除。锁消除可以节省毫无意义的请求锁的时间。
锁粗化:我们在写代码时推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待线程也能尽快拿到锁。
注意:在大部分情况下,上面的原则都是没有问题的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,那么会带来很多不必要的性能消耗
5. 总结
- 相对于synchronized块的代码锁,volatile应该是提供了一个轻量级的针对共享变量的锁,当我们在多个线程间使用共享变量进行通信的时候需要考虑将共享变量用volatile来修饰。
- volatile是一种稍弱的同步机制,在访问volatile变量时不会执行加锁操作,也就不会执行线程阻塞,因此volatilei变量是一种比synchronized关键字更轻量级的同步机制。
- 在两个或者更多的线程需要访问的成员变量上使用volatile。当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile。
- 由于使用volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字
CAS算法和原子变量
CAS
Compare and Swap比较并交换
- CAS是一种硬件对并发的支持,针对多处理器操作而设计的处理器中的一种特殊指令,用于管理对共享数据的并发访问。
- 总共由三个操作数,一个内存值v,一个线程本地内存旧值a(期望操作前的值)和一个新值b,在操作期间先拿旧值a和内存值v比较有没有发生变化,如果没有发生变化,才能内存值v更新成新值b,发生了变化则不交换。
- CAS 是一种无锁的非阻塞算法的实现。
下面用代码模拟下CAS
public class CompareAndSwapDemo {
public static void main(String[] args) {
final CompareAndSwap cas = new CompareAndSwap();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
int expectedValue = cas.get();
boolean b = cas.compareAndSet(expectedValue, (int)(Math.random() * 100));
System.out.println(b);
}
}).start();
}
}
}
class CompareAndSwap{
private int value;
public synchronized int get(){
return value;
}
public synchronized int compareAndSwap(int expectedValue, int newValue){
int oldValue = value;
if(oldValue == expectedValue){
this.value = newValue;
}
return oldValue;
}
public synchronized boolean compareAndSet(int expectedValue, int newValue){
return expectedValue == compareAndSwap(expectedValue, newValue);
}
}
循环CAS算法则是不停的执行CAS操作。java.util.concurrent.atomic包下的原子变量类型,比如AtomicInteger,都使用了这些底层的JVM支持为数字类型的引用类型提供一种高效的CAS操作。
java中synchronized关键字在jdk1.5版本之后,也做出了优化,在这之前synchronized一直是重量级的锁,不管什么情况上来就直接给对象加上互斥锁,导致在某些情况下效率低下。1.5版本之后,对synchronized采用了锁升级的策略,
由偏向锁→轻量级锁→自旋锁→重量级锁。 其中轻量级锁采用的就是类似于cas算法来实现的,
关于java中的轻量级锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。
CAS虽然很高效的解决原子操作,但是CAS仍然存在三大问题:
- ABA问题
- 循环时间长开销大
- 只能保证一个共享变量的原子操作
原子变量
类的工具包,支持在单个变量上解除锁的线程安全编程。事实上,此包中的类可将 volatile 值、字段和数组元素的概念扩展到那些也提供原子条件更新操作的
java.util.concurrent.atomic 包下提供了一些原子操作的常用类:
- AtomicBoolean
- AtomicInteger
- AtomicLong
- AtomicReference
- AtomicIntegerArray
- AtomicLongArray
- AtomicMarkableReference
- AtomicReferenceArray
- AtomicStampedReference