目录
wait和notify为什么需要在synchronized里面
JMM如何解决原子性、可见性、有序性问题
在Java中提供了一系列和并发处理相关的关键字,比如volatile、synchronized、fina、juc等,这些就是Java内存模型封装了底层的实现后提供给开发人员使用的关键字,在开发多线程代码时,我们可以直接使用synchronized关键字来控制并发,使我们不需要关心底层的编译器优化、缓存一致性问题。所以在Java内存模型中,除了定义了一套规范,还提供了开放的指令在底层进行封装后,提供给开发人员使用。
原子性
在Java中提供了两个高级的字节码指令monitorenter和monitorexit,在Java中对应的synchronized来保证代码块内的操作时原子的
可见性
Java中的volatile关键字提供了一个功能,那就是被修饰的变量在被修改后可立即同步到主内存,被其修饰的变量在每次使用之前都从主内存刷新,因此,可以使用volatile来保证多线程操作时变量的可见性。
除了volatile,Java中的synchronized和final两个关键字也可以实现可见性
有序性
在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排序。synchronized关键字保证同一时刻只允许一条线程操作。
volatile
volatile如何保证可见性
volatile关键字修饰的共享变量,在进行写操作的时候会多出一个lock前缀的汇编指令,这个指令,会触发CPU高速缓存总线锁或者缓存锁,通过缓存一致性协议(MESI)解决可见性问题。
对于声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀指令,把这个变量所在的缓存行的数据协会到系统内存,再根据MESI缓存一致性歇息,来保证多CPU下的各个高速缓存中的数据一致性。
volatile防止指令重排序
指令重排的目的时为了最大化的提高CPU利用率以及性能,CPU的乱序执行优化在单核时代并不影响正确性,但是在多核时代的多线程能够在不同的核心上实现真正的并行,一旦线程之间共享数据,就可能会出现一些不可预料的问题。
指令重排序必须要遵循的原则时,不影响代码执行的最终结果,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。as-if-serial,不管怎么重排序,单线程的执行结果不会改变,编译器、处理器都必须遵守as-if-serial语义
多核心多线程下的指令重排序影响
/**
* @author King Chen
* @Date: 2019/3/5 23:01
*/
public class Test5 {
private static int x = 0, y = 0;
private static int a = 0, b = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
a = 1;
x = b;
});
Thread t2 = new Thread(() -> {
b = 1;
y = a;
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("x = " + x + " y = " + y);
}
}
内存屏障
内存屏障需要解决我们前面提到的两个问题,一个是编译器的优化乱序和CPU的执行乱序,我们可以分别使用优化屏障和内存屏障这两个机制来解决
从CPU层面了解一下什么是内存屏障
CPU的乱序执行,本质还是,由于再多CPU汲取上,每个CPU都存在cache,当一个特定数据第一次被特定一个CPU获取时,由于再该CPU缓存中不存在,就会从内存中去获取,被加载到CPU告诉缓存中后就能从缓存中快速访问。当某个CPU进行写操作时,它必须确保其他的CPU已经将这个数据从他们的缓存中移除,这样才能让其他CPU安全的修改数据。显然,存在多个cache时,我们必须通过一个cache一致性协议来避免数据不一致问题,而这个通讯的过程就可能导致乱序访问的问题,也就是运行时的内存乱序访问。
现在的CPU架构都提供了内存屏障功能,在x86的CPU中,实现了相应的内存屏障
写屏障(store barrier)、读屏障(load barrier)和全屏障(full barrier),主要作用是
- 防止指令之间的重排序
- 保证数据的可见性
总结:内存屏障只是解决顺序一致性问题,不解决缓存一致性问题,缓存一致性是由CPU的缓存锁以及MESI协议来完成的。而缓存一致性协议只关心缓存一致性,不关心顺序一致性。
在编译器层面,通过volatile关键字,取消编译器层面的缓存和重排序。保证编译程序时在优化屏障之前的指令不会再优化屏障之后执行。这就保证了编译时期的优化不会影响到世纪代码逻辑顺序。
volatile为什么不能保证原子性
/**
* @author King Chen
* @Date: 2019/3/6 17:43
*/
public class Test52 {
volatile int i;
public static void main(String[] args) {
new Test52().incr();
}
public void incr() {
i++;
}
}
通过javap -c Demo.class,查看字节码
对一个原子递增操作,会分为三个步骤:1、读取volatile变量的值到local;2、增加变量的值;3、把local的值写回让其他线程可见
synchronized的使用
在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它位重量级锁。但是,随着1.6对synchronized进行了各种优化后,有些情况下它就并不那么重了,Java SE 1.6中为了减少获得锁和释放锁带来的性能小号而引入的偏向锁和轻量级锁,以及锁的存储结构和升级过程。我们仍然沿用前面使用的案例,用过synchronized关键字来修饰在inc的方法上:
/**
* @author King Chen
* @Date: 2019/3/10 22:16
*/
public class Test53 {
private static int count = 0;
public static void inc() {
synchronized (Test53.class) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100; i++) {
new Thread(() -> Test53.inc()).start();
}
Thread.sleep(3000);
System.out.println("运行结果:" + count);
}
}
synchronized的三种使用方式
synchronized有三种方式来加锁,分别是
- 修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁;
- 静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁;
- 修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码块前要获得给定对象的锁。
synchronized括号后面的对象
synchronized括号后面的对象是一把锁,在Java中任意一个对象都可以成为锁,简单来说,我们把Object比喻成一个key,拥有这个key的线程才能执行这个方法,拿到这个key以后再执行方法过程中,这个key随身携带的,并且只有一把。如果后续的线程想访问当前方法,因为没有key所以不能访问只能再门口等着,等之前的线程把key放回去。所以,synchronized锁丁的对象必须是同一个,如果是不同对象,就意味着是不同的房间钥匙,对于访问者来说是没有任何影响的。
synchronized的字节码指令
通过javap -v 来查看对应代码的字节码指令,对于同步块的实现使用了monitorenter和monitorexit指令,前面我们在讲JMM的时候,提到过这两个指令,他们隐式的执行了Lock和UnLock操作,用于提供原子性保证。
monitorenter指令插入到同步代码块开始的位置、monitorexit指令插入到同步代码块结束位置,jvm需要保证每个monitorenter都有一个monitorexit对应。
这两个指令,本质上都是对一个对象的监视器(monitor)进行获取,这个过程式排他的,也就是说同一时刻只能有一个线程获取到由synchronized所保护对象的监视器
线程执行到monitorenter指令时,会尝试获取对象所对应的monitor所有权,也就是尝试获取对象的锁;而执行monitorexit,就是释放monitor的所有权
synchronized的锁的原理
JDK1.6以后对synchronized锁进行了优化,包含偏向锁、轻量级锁、重量级锁;在了解synchronized锁之前,我们需要了解两个重要的概念,一个是对象头、另一个是monitor
Java对象头
在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对其填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键。
Mark Word
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit)
在源码中体现
如果想更深入了解对象头在JVM源码中的定义,需要关心几个文件,oop.hpp/markOop.hpp
oop.hpp,每个java Object在JVM内部都有一个native的C++对象oop/oopDesc与之对应。现在oop.hpp中看oopDesc的定义
_mark被声明在oopDesc类的顶部,所以这个_mark可以认为是一个头部,前面我们讲过头部保存了一些重要的状态和标识信息,在markOop.hpp文件中有一些注释说明markOop的内存布局
Monitor
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述位一种同步机制。所有的Java对象是天生的Monitor,每个object的对象里markOop->monitor()里可以保存ObjectMonitor对象。从源码层面分析一下monitor对象
- oop.hpp下的oopDesc类是JVM对象的顶级基类,多以每个object对象都包含markOop
- markOop.hpp**中**markOopDesc继承自oopDesc,并扩展了自己的monitor方法,这个方法返回一个ObjectMonitor只针对象
- objectMonitor.hpp,在hotspot虚拟机中,采用objectmonitor类来事项monitor
synchronized的锁升级和获取过程
了解了对象头以及monitor后,接下来去分析synchronized的锁的实现,就会非常简单了。synchronized的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的几倍从低到高逐步升级,无锁->偏向所->轻量级锁->重量级锁。
自旋锁(CAS)
自旋锁就是让不满足条件的线程等待一段时间,而不是立即挂起。看持有锁的线程是否能够很快释放锁,如何实现自旋锁呢?其实就是一段没有任何意义的循环。
虽然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程布恩那个在很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何有意义的工作。所以,自选等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。
偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和推出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,标识线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(标识当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
轻量级锁
引入轻量级锁的主要目的是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁
重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
前面在讲Java对象头的时候,降到了monitor对象,在hotspot虚拟机中,通过ObjectMonitor类来实现monitor。他的锁的获取过程的体现会简单很多
wait和notify
wait和notify是用来让线程进入等待状态以及使得线程唤醒的两个操作
/**
* @author King Chen
* @Date: 2019/3/12 9:35
*/
public class Test55 extends Thread {
private Object lock;
public Test55(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
System.out.println("开始执行 thread wait");
try {
lock.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("执行结束 thread wait");
}
}
}
/**
* @author King Chen
* @Date: 2019/3/12 9:38
*/
public class Test56 extends Thread {
private Object lock;
public Test56(Object lock) {
this.lock = lock;
}
@Override
public void run() {
synchronized (lock) {
System.out.println("开始执行 thread notify");
lock.notify();
System.out.println("执行结束 thread notify");
}
}
}
wait和notify的原理
调用wait方法,首先会获取监视器锁,获得成功以后,会让当前线程进入等待状态进入等待队列并且释放锁;然后
当其他线程调用notify或者notifyall以后,会选择从等待队列中唤醒任意一个线程,而执行完notify方法以后,并不
会立马唤醒线程,原因是当前的线程仍然持有这把锁,处于等待状态的线程无法获得锁。必须要等到当前的线程执
行完按monitorexit指令以后,也就是锁被释放以后,处于等待队列中的线程就可以开始竞争锁了
wait和notify为什么需要在synchronized里面
wait方法的语义有两个,一个是释放当前的对象锁、另一个是使得当前线程进入阻塞队列, 而这些操作都和监视器是
相关的,所以wait必须要获得一个监视器锁
而对于notify来说也是一样,它是唤醒一个线程,既然要去唤醒,首先得知道它在哪里?所以就必须要找到这个对
象获取到这个对象的锁,然后到这个对象的等待队列中去唤醒一个线程。
同步锁
我们知道,锁是用来控制多个线程访问共享资源的方式,一般来说,一个锁能够防止多个线程同时访问共享资源,
在Lock接口出现之前,Java应用程序只能依靠synchronized关键字来实现同步锁的功能,在java5以后,增加了JUC
的并发包且提供了Lock接口用来实现锁的功能,它提供了与synchroinzed关键字类似的同步功能,只是它比
synchronized更灵活,能够显示的获取和释放锁。
Lock的初步使用
Lock是一个接口,核心的两个方法lock和unlock,它有很多实现类,如ReentrantLock、ReentrantReadWriteLock;
ReentantLock
重入锁,表示支持重新进入的锁,也就是说,如果当前线程t1通过调用lock方法获取锁之后,再次调用lock,是不会再阻塞去获取锁的,直接增加重试次数就行了。
/**
* @author King Chen
* @Date: 2019/3/12 13:11
*/
public class Test57 {
static Lock lock = new ReentrantLock();
private static int count = 0;
public static void inc() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
count++;
lock.unlock();
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
Test57.inc();
}).start();
}
Thread.sleep(3000);
}
}
ReentrantReadWriteLock
我们以前理解的锁,基本都是排他锁,也就是这些锁在同一时刻只允许一个线程进行访问,而读写所在同一时刻可以允许多个线程访问,但是在写线程访问时,所有的读线程和其他写线程都会被阻塞。读写锁维护了一堆锁,一个读锁、一个写锁;一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多写少的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。
/**
* @author King Chen
* @Date: 2019/3/12 13:55
*/
public class Test58 {
static Map<String, Object> cacheMap = new HashMap<>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock read = rwl.readLock();
static Lock write = rwl.writeLock();
public static final Object get(String key) {
System.out.println("开始读取数据");
read.lock();
try {
return cacheMap.get(key);
} finally {
read.unlock();
}
}
public static final Object put(String key, String value) {
write.lock();
System.out.println("开始写数据");
try {
return cacheMap.put(key, value);
} finally {
write.unlock();
}
}
}
在这个案例中,通过hashmap来模拟了一个内存缓存,然后使用读写锁来保证这个内存缓存的线程安全性。当执行读操作的时候,需要获取读锁,在并发访问的时候,读锁不会被阻塞,因为读操作不会影响执行结果。
在执行写操作时,线程必须要获取写锁,当已经有线程持有写锁的情况下,当前线程会被阻塞,只有当写锁释放以后,其他读写操作才能继续执行。使用读写锁提升读写操作的并发性,也保证每次写操作对所有的读写操作的可见性
- 读锁与读锁可以共享
- 读锁与写锁不可以共享
- 写锁与写锁不可以共享
Lock和synchronized的简单对比
通过对Lock的使用以及对synchronized的了解,基本上可以对比出这两种锁的区别,同样这也是面试中比较常见的问题
- 层次上,一个是关键字、一个是类
- 使用上,lock具备了更大的灵活性,可以控制锁的释放和获取;而synchronized的锁的释放是被动的,当出现异常或者同步代码块执行完后,才会释放锁
- lock可以判断锁状态,synchronized无法做到
- lock可以实现公平锁、非公平锁;而synchronized只有非公平锁。
AQS
Lock之所以能实现线程安全的锁,主要的核心就是AQS(AbstractQueuedSynchronizer),AbstractQueuedSynchronizer提供了一个FIFO队列,可以看做是一个用来实现锁以及其他需要同步功能的框架。这里简称该类位AQS。AQS的使用依靠继承来完成,子类通过继承自AQS并实现所需要的方法来管理同步状态。例如常见的ReentrantLock,CountDownLatch等AQS的两种功能
从使用上来说,AQS的功能可以分为两种:独占和共享。
- 独占锁模式下,每次只能有一个线程持有锁,比如前面演示过的ReentrantLock就是以独占方式实现的互斥锁
- 共享锁模式下,允许多个线程同时获取锁,并发访问共享资源,比如ReentrantReadWriteLock。
很明显,独占锁是一种悲观保守的加锁策略,它限制了读/读冲突,如果某个制度线程获取锁,则其他读线程都只能等待,这种情况下就限制了不必要的并发行,因为读操作并不会影响数据的一致性。共享锁则是一种乐观锁,它放宽了加锁策略,允许多个执行读操作的线程同时访问共享资源
AQS的内部实现
同步器依赖内部的同步队列(一个FIFO双向队列)来完成同步状态的管理,当前线程获取同步状态失败时,同步器会将当前线程以及等待状态等信息构造成为一个节点(Node)并将其加入同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取同步状态。
Node的主要属性如下
static final class Node {
int waitStatus; //表示节点的状态,包含cancelled(取消);condition 表示节点在等待condition
也就是在condition队列中
Node prev; //前继节点
Node next; //后继节点
Node nextWaiter; //存储在condition队列中的后继节点
Thread thread; //当前线程
}
AQS类底层的数据结构是使用双向链表,是队列的一种实现。包括一个head节点和一个tail节点,分别表示头节点和尾节点,其中头节点不存储Thread,仅保存next节点的引用。
当一个线程成功地获取了同步状态(或者锁),其他线程将无法获取到同步状态,转而被构造成为节点并加入到同步队列,而这个加入队列的过程必须要保证线程安全,因此
同步器提供了一个基于CAS的设置尾节点的方法:CompareAndSetTail(Node expect,Nodeupdate),它需要传递当前线程“认为”的尾节点和当前节点,只有设置成功后,当前节点才正式与之前的尾节点建立关联。
同步队列遵循FIFO,首节点是获取同步状态成功的节点,首节点的线程在释放同步状态时,将会唤醒后继节点,而后继节点将会在获取同步状态成功时将自己设置尾首节点。
设置首节点是通过获取同步状态成功的线程来完成的,由于只有一个线程能够成功获取到同步状态,因此设置头节点的方法并不需要使用CAS来保证,它只需要将首节点设置成为原首节点的后继节点并断开原首节点的next引用即可
compareAndSet
AQS中,除了本身的链表结构以外,还有一个很关键的功能,