1. 缓存一致协议
最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。
2. JMM
2.1 概念
内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象描述,不同架构下的物理机拥有不一样的内存模型,Java虚拟机是一个实现了跨平台的虚拟系统,因此它也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)。
2.2 JMM结构规范
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
(1) 线程A把本地内存A中更新过的共享变量刷新到主内存中去。
(2) 线程B到主内存中去读取线程A之前已更新过的共享变量。
2.3 JVM三个特征
- 原子性
一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
x = 10; //语句1
y = x; //语句2
x++; //语句3
x = x +1; //语句4
哪些操作是原子性操作?其实只有语句 1 是原子性操作,其他三个语句都不是原子性操作。
语句 1 是直接将数值 10 赋值给 x,也就是说线程执行这个语句的会直接将数值 10 写入到工作内存中。
语句 2 实际上包含 2 个操作,它先要去读取 x 的值,再将 x 的值写入工作内存,虽然读取 x 的值以及 将 x 的值写入工作内存 这 2 个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++ 和 x = x+1 包括 3 个操作:读取 x 的值,进行加 1 操作,写入新的值。
所以上面 4 个语句只有语句 1 的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
从上面可以看出,Java 内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过 synchronized 和 Lock 来实现。由于 synchronized 和 Lock 能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
- 可见性
一个线程对共享变量做了修改之后,其他的线程立即能够看到(感知到)该变量的这种修改(变化)。
对于可见性,Java 提供了 volatile 关键字来保证可见性。
当一个共享变量被 volatile 修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过 synchronized 和 Lock 也能够保证可见性,synchronized 和 Lock 能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
- 有序性
对于一个线程的代码而言,我们总是以为代码的执行是从前往后的,依次执行的。这么说不能说完全不对,在单线程程序里,确实会这样执行;但是在多线程并发时,程序的执行就有可能出现乱序。用一句话可以总结为:在本线程内观察,操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行语义(WithIn Thread As-if-Serial Semantics)”,后半句是指“指令重排”现象和“工作内存和主内存同步延迟”现象。
//线程1:
context = loadContext(); //语句1
inited =true; //语句2
//线程2:
while(!inited ){
sleep()
}
doSomethingwithconfig(context);
上面代码中,由于语句 1 和语句 2 没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程 1 执行过程中先执行语句 2,而此是线程 2 会以为初始化工作已经完成,那么就会跳出 while 循环,去执行 doSomethingwithconfig(context) 方法,而此时 context 并没有被初始化,就会导致程序出错。
3. volatile
3.1 volatile 关键字的两层语义
一旦一个共享变量(类的成员变量、类的静态成员变量)被 volatile 修饰之后,那么就具备了两层语义:
- 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
- 禁止进行指令重排序。
先看一段代码,假如线程 1 先执行,线程 2 后执行:
//线程1
boolean stop = false;
while(!stop){
doSomething();
}
//线程2
stop = true;
下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程 1 在运行的时候,会将 stop 变量的值拷贝一份放在自己的工作内存当中。
那么当线程 2 更改了 stop 变量的值之后,但是还没来得及写入主存当中,线程 2 转去做其他事情了,那么线程 1 由于不知道线程 2 对 stop 变量的更改,因此还会一直循环下去。
但是用 volatile 修饰之后就变得不一样了:
- 使用 volatile 关键字会强制将修改的值立即写入主存;
- 使用 volatile 关键字的话,当线程 2 进行修改时,会导致线程 1 的工作内存中缓存变量 stop 的缓存行无效(反映到硬件层的话,就是 CPU 的 L1 或者 L2 缓存中对应的缓存行无效);
- 由于线程 1 的工作内存中缓存变量 stop 的缓存行无效,所以线程 1 再次读取变量 stop 的值时会去主存读取。
3.2 volatile 保证原子性吗?
下面看一个例子:
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for ( int i = 0 ; i < 10 ; i++ ) {
new Thread(){
public void run() {
for ( int j = 0 ; j < 1000 ; j++ ) {
test.increase();
}
}; }.start();
}
while(Thread.activeCount() > 1)
//保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc)
}
}
大家想一下这段程序的输出结果是多少?也许有些朋友认为是 10000。
因为上面是对变量 inc 进行自增操作,由于 volatile 保证了可见性,那么在每个线程中对 inc 自增完之后,在其他线程中都能看到修改后的值啊,所以有 10 个线程分别进行了 1000 次操作,那么最终 inc 的值应该是 100010=10000。
但是事实上运行它会发现每次运行结果都不一致,都是一个小于 10000 的数字。
这里面就有一个误区了,volatile 关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是 volatile 没办法保证对变量的操作的原子性。
在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加 1 操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
假如某个时刻变量 inc 的值为 10,
线程 1 对变量进行自增操作,线程 1 先读取了变量 inc 的原始值,然后线程 1 被阻塞了;
然后线程 2 对变量进行自增操作,线程 2 也去读取变量 inc 的原始值,由于线程 1 只是对变量 inc 进行读取操作,而没有对变量进行修改操作,所以不会导致线程 2 的工作内存中缓存变量 inc 的缓存行无效,所以线程 2 会直接去主存读取 inc 的值,发现 inc 的值时 10,然后进行加 1 操作,并把 11 写入工作内存,最后写入主存。
然后线程 1 接着进行加 1 操作,由于已经读取了 inc 的值,注意此时在线程 1 的工作内存中 inc 的值仍然为 10,所以线程 1 对 inc 进行加 1 操作后 inc 的值为 11,然后将 11 写入工作内存,最后写入主存。
那么两个线程分别进行了一次自增操作后,inc 只增加了 1。
把上面的代码改成以下任何一种都可以达到效果:
采用 synchronized:
public class Test {
public int inc = 0 ;
public synchronized void increase() {
inc++;
}
public static void main(String[] args) {
final Test test = new Test();
for ( int i = 0 ; i < 10 ; i++ ){
new Thread(){
public void run() {
for ( int j = 0 ; j < 1000 ; j++ )
test.increase();
};
}.start();
}
while(Thread.activeCount()>1)
//保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
采用 Lock:
public class Test {
public int inc = 0;
Lock lock = new ReentrantLock();
public void increase() {
lock.lock();
try {
inc++;
} finally{
lock.unlock();
}
}
public static void main(String[] args) {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
while(Thread.activeCount()>1) //保证前面的线程都执行完
Thread.yield();
System.out.println(test.inc);
}
}
3.3 volatile 能保证有序性吗?
在前面提到 volatile 关键字能禁止指令重排序,所以 volatile 能在一定程度上保证有序性。
volatile 关键字禁止指令重排序有两层意思:
- 当程序执行到 volatile 变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对 volatile 变量访问的语句放在其后面执行,也不能把 volatile 变量后面的语句放到其前面执行。
可能上面说的比较绕,举个简单的例子:
//x、y为非volatile变量
//flag为volatile变量
x = 2; //语句1
y = 0; //语句2
flag = true; //语句3
x = 4; //语句4
y = -1; //语句5
由于 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句 3 放到语句 1、语句 2 前面,也不会讲语句 3 放到语句 4、语句 5 后面。但是要注意语句 1 和语句 2 的顺序、语句 4 和语句 5 的顺序是不作任何保证的。
并且 volatile 关键字能保证,执行到语句 3 时,语句 1 和语句 2 必定是执行完毕了的,且语句 1 和语句 2 的执行结果对语句 3、语句 4、语句 5 是可见的。
3.4 volatile 的原理和实现机制
观察加入 volatile 关键字和没有加入 volatile 关键字时所生成的汇编代码发现,加入 volatile 关键字时,会多出一个 lock 前缀指令
lock 前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供 3 个功能:
- 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
- 它会强制将对缓存的修改操作立即写入主存;
- 如果是写操作,它会导致其他 CPU 中对应的缓存行无效。
3.5 使用 volatile 关键字的场景
synchronized 关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而 volatile 关键字在某些情况下性能要优于 synchronized,但是要注意 volatile 关键字是无法替代 synchronized 关键字的,因为 volatile 关键字无法保证操作的原子性。通常来说,使用 volatile 必须具备以下 2 个条件:
- 对变量的写操作不依赖于当前值。
就是如果变量a定义为volatile变量,做诸如该变量没有包含在具有其他变量的不变式a++,a=a+2,a=2*a等等,这些操作,在多线程场景下会出现共享变量不一致的情形。原因就是volatile无法保证原子性。 - 该变量没有包含在具有其他变量的不变式中。
@NotThreadSafe
public class NumberRange {
private int lower, upper;
public int getLower() {
return lower;
}
public int getUpper() {
return upper;
}
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
代码显示了一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的。
事实上,我的理解就是上面的 2 个条件需要保证操作是原子性操作,才能保证使用 volatile 关键字的程序在并发时能够正确执行。
下面列举几个 Java 中使用 volatile 的几个场景。
- 状态标记量
volatile boolean flag = false;
while(!flag){
doSomething();
}
public void setFlag() {
flag = true;
}
- double check
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}
《Java 中的双重检查(Double-Check)》http://blog.csdn.net/dl88250/article/details/5439024
4. synchronized
说句题外话,本来以为很快能结束这段,但是没想到昨天学synchronized也学了一天,今天才开始整理,希望可以学的透一点,不说了,开干吧。
4.1 synchronized保证三大特性
- 保证原子性
for (int i = 0; i < 1000; i++) {
synchronized (Test01Atomicity.class) {
number++;
}
}
对number++;增加同步代码块后,保证同一时间只有一个线程操作number++;。就不会出现安全问题。synchronized保证原子性的原理,synchronized保证只有一个线程拿到锁,能够进入同步代码块。
-
保证可见性
synchronized保证可见性的原理,执行synchronized时,会对应lock原子操作会刷新工作内存中共享变 量的值。
- 保证有序性
synchronized保证有序性的原理,我们加synchronized后,依然会发生重排序,只不过,我们有同步 代码块,可以保证只有一个线程执行同步代码中的代码,保证了有序性。
4.2 synchronized的特性
4.2.1 可重入性
一个线程可以多次执行synchronized,重复获取同一把锁(同一个线程获得锁之后,可以直接再次获取该锁)。
synchronized是可重入锁,内部锁对象中会有一个计数器记录线程获取几次锁啦,在执行完同步代码块 时,计数器的数量会-1,知道计数器的数量为0,就释放这个锁。
4.2.2 不可中断性
不可中断是指,当一个线程获得锁后,另一个线程一直处于阻塞或等待状态,前一个线程不释放锁,后 一个线程会一直阻塞或等待,不可被中断。
synchronized属于不可被中断; Lock的lock方法是不可中断的; Lock的tryLock方法是可中断的。
synchronized代码块中抛异常会释放锁。
4.3 synchronized原理
4.3.1 javap反汇编
-
monitorenter
synchronized的锁对象会关联一个monitor,这个monitor不是我们主动创建的,是JVM的线程执行到这个 同步代码块,发现锁对象没有monitor就会创建monitor,monitor内部有两个重要的成员变量owner:拥有 这把锁的线程,recursions会记录线程拥有锁的次数,当一个线程拥有monitor后其他线程只能等待。
-
monitorexit
能执行monitorexit指令的线程一定是拥有当前对象的monitor的所有权的线程。
执行monitorexit时会将monitor的进入数减1。当monitor的进入数减为0时,当前线程退出 monitor,不再拥有monitor的所有权,此时其他被这个monitor阻塞的线程可以尝试去获取这monitor的所有权
monitorexit插入在方法结束处和异常处,JVM保证每个monitorenter必须有对应的monitorexit。
- 同步方法
可以看到同步方法在反汇编后,会增加 ACC_SYNCHRONIZED 修饰。会隐式调用monitorenter和 monitorexit。在执行同步方法前会调用monitorenter,在执行完同步方法后会调用monitorexit。
面试题:synchronized与Lock的区别
- synchronized是关键字,而Lock是一个接口。
- synchronized会自动释放锁,而Lock必须手动释放锁。
- synchronized是不可中断的,Lock可以中断也可以不中断。
- 通过Lock可以知道线程有没有拿到锁,而synchronized不能。
- synchronized能锁住方法和代码块,而Lock只能锁住代码块。
- Lock可以使用读锁提高多线程读效率。
- synchronized是非公平锁,ReentrantLock可以控制是否是公平锁。
4.3.2 深入JVM源码
-
monitor监视器锁
- _owner:初始时为NULL。当有线程占有该monitor时,owner标记为该线程的唯一标识。当线程 释放monitor时,owner又恢复为NULL。owner是一个临界资源,JVM是通过CAS操作来保证其线 程安全的。
- _cxq:竞争队列,所有请求锁的线程首先会被放在这个队列中(单向链接)。_cxq是一个临界资 源,JVM通过CAS原子指令来修改_cxq队列。修改前_cxq的旧值填入了node的next字段,_cxq指 向新值(新线程)。因此_cxq是一个后进先出的stack(栈)。
- _EntryList:_cxq队列中有资格成为候选资源的线程会被移动到该队列中。
- _WaitSet:因为调用wait方法而被阻塞的线程会被放在该队列中。
每一个Java对象都可以与一个监视器monitor关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized圈起来的同步方法或者代码块时,该线程得先获取到synchronized修饰的对象 对应的monitor。
我们的Java代码里不会显示地去创造这么一个monitor对象,我们也无需创建,事实上可以这么理解: monitor并不是随着对象创建而创建的。我们是通过synchronized修饰符告诉JVM需要为我们的某个对 象创建关联的monitor对象。
- monitor竞争
- 通过CAS尝试把monitor的owner字段设置为当前线程。
- 如果设置之前的owner指向当前线程,说明当前线程再次进入monitor,即重入锁,执行 recursions ++ ,记录重入的次数。
- 如果当前线程是第一次进入该monitor,设置recursions为1,_owner为当前线程,该线程成功获 得锁并返回。
- 如果获取锁失败,则等待锁的释放。
- monitor等待
- 当前线程被封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ。
- 在for循环中,通过CAS把node节点push到_cxq列表中,同一时刻可能有多个线程把自己的node 节点push到_cxq列表中。
- node节点push到_cxq列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当 前线程挂起,等待被唤醒。
- 当该线程被唤醒时,会从挂起的点继续执行,通过 ObjectMonitor::TryLock 尝试获取锁。
- monitor释放
- 退出同步代码块时会让_recursions减1,当_recursions的值减为0时,说明线程释放了锁。
- 根据不同的策略(由QMode指定),从cxq或EntryList中获取头节点,通过ObjectMonitor::ExitEpilog 方法唤醒该节点封装的线程,唤醒操作最终由unpark完成,
- monitor是重量级锁
操作系统的体系架构分为:用户空间(应用程序的活动空间)和内核。
内核:本质上可以理解为一种软件,控制计算机的硬件资源,并提供上层应用程序运行的环境。
用户空间:上层应用程序活动的空间。应用程序的执行必须依托于内核提供的资源,包括CPU资源、存 储资源、I/O资源等。
系统调用:为了使上层应用能够访问到这些资源,内核必须为上层应用提供访问的接口:即系统调用。
用户态和内核态:所有进程初始都运行于用户空间,此时即为用户运行状态(简称:用户态);但是当它调用系统调用执 行某些操作时,例如 I/O调用,此时需要陷入内核中运行,我们就称进程处于内核运行态(或简称为内 核态)。
系统调用的过程:
- 用户态程序将一些数据值放在寄存器中, 或者使用参数创建一个堆栈, 以此表明需要操作系统提 供的服务。
- 用户态程序执行系统调用。
- CPU切换到内核态,并跳到位于内存指定位置的指令。
- 系统调用处理器(system call handler)会读取程序放入内存的数据参数,并执行程序请求的服务。
- 系统调用完成后,操作系统会重置CPU为用户态并返回系统调用的结果。
用户态切换至内核态需要传递许多变量,同时内核还需要保护好用户态在切换时的一些寄存器 值、变量等,以备内核态切换回用户态。这种切换就带来了大量的系统资源消耗,这就是在 synchronized未优化之前,效率低的原因。
4.4 JDK6 synchronized优化
4.4.1 CAS
定义:Compare And Swap(比较相同再交换)。是现代CPU广泛支持的一种对内存中的共享数 据进行操作的一种特殊指令。
作用::CAS可以将比较和交换转换为原子操作,这个原子操作直接由CPU保证。CAS可以保证共 享变量赋值时的原子操作。CAS操作依赖3个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧 的预估值X等于内存中的值V,就将新的值B保存到内存中。
Unsafe实现CAS:
过程:
其中this,valueOffset用来确定var5的值,也就是value 的值。
Thread1 进入,调用incrementAndGet,调用getAndAddInt, var5通过var1->this , var2->valueOffset决定,即var5->value,此时value=0, 所以var5=0, 此处的var5我们称之为“旧的预估值”。 接着进入while循环的判断,判断通过var1, va2找到var5, 此时var5=value,此时的var5我们叫“内存最新的值” ,value 还是0, 此时内存最新的值=旧的预估值,所以var5=var5+var4=1, 并且跳出循环,返回var5=1, 更新value=1。
显而易见,如果有Thread2,可能引起内存最新的值不等于旧的预估值的情况,所以会进入循环,直到t1更新完var5。
CAS需要3个值:内存地址V,旧的预期值A,要修改的新值B,如果内存地址V和旧的预期值 A相等就修改内存地址值为B。
4.4.2 synchronized锁升级过程
无锁-->偏向锁-->轻量级锁-->重量级锁
4.4.3 Java 对象的布局
在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下图所示:
- 对象头:当一个线程尝试访问synchronized修饰的代码块时,它首先要获得锁,那么这个锁到底存在哪里呢?是 存在锁对象的对象头中的。
对象头又分两部分,一是Mark Word, 二是Klass pointer。
Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、 线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下:
klass pointer:这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的 实例。 - 实例数据:就是类中定义的成员变量。
- 对齐填充:对齐填充并不是必然存在的,也没有什么特别的意义,他仅仅起着占位符的作用,由于HotSpot VM的 自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的 整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充 来补全。
4.4.4 偏向锁
- 定义
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程,会在对象头存储锁偏向的线程ID,以后该线程进入和退出同步块时只需要检查是否为偏向锁、锁标志位以及 ThreadID即可。
不过一旦出现多个线程竞争时必须撤销偏向锁,所以撤销偏向锁消耗的性能必须小于之前节省下来的 CAS原子操作的性能消耗,不然就得不偿失了。
- 原理
当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:
- 虚拟机将会把对象头中的标志位设为“01”,即偏向模式。
-
同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作 成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何 同步操作,偏向锁的效率高。
持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁 的效率高。
- 偏向锁的撤销
- 偏向锁的撤销动作必须等待全局安全点
- 暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态
- 撤销偏向锁,恢复到无锁(标志位为 01)或轻量级锁(标志位为 00)的状态
- 偏向锁好处
偏向锁是在只有一个线程执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以 提高带有同步但无竞争的程序性能。 - 原理总结
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操 作把获取到这个锁的线程的ID记录在对象的Mark Word之中 ,如果CAS操作成功,持有偏向锁的线程以后每 次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。
4.4.5 轻量级锁
获取锁:
- 判断当前对象是否处于无锁状态(hashcode、0、01),如果是,则JVM首先将在当前线程的栈帧 中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方 把这份拷贝加了一个Displaced前缀,即Displaced Mark Word),将对象的Mark Word复制到栈 帧中的Lock Record中,将Lock Reocrd中的owner指向当前对象。
- JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果成功表示竞争到 锁,则将锁标志位变成00,执行同步操作。
- 如果失败则判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持 有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻 量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态。
释放: - 取出在获取轻量级锁保存在Displaced Mark Word中的数据。
- 用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功。 3. 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。
- 如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级 锁。
- 小结
轻量级锁的原理是什么?
将对象的Mark Word复制到栈帧中的Lock Recod中。Mark Word更新为指向Lock Record的指针。
轻量级锁好处是什么?
在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。
4.4.6 自旋锁
如果物理机器有一个以上的处理器,能让两个 或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行 时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只需让线程执行一个忙循环(自 旋) , 这项技术就是所谓的自旋锁。
4.4.7 锁消除
public class Demo01 {
public static void main(String[] args) {
contactString("aa", "bb", "cc");
}
public static String contactString(String s1, String s2, String s3) {
return new StringBuffer().append(s1).append(s2).append(s3).toString();
}
}
StringBuffer的append ( ) 是一个同步方法,锁就是this也就是(new StringBuilder())。虚拟机发现它的 动态作用域被限制在concatString( )方法内部。也就是说, new StringBuilder()对象的引用永远不会“逃 逸”到concatString ( )方法之外,其他线程无法访问到它,因此,虽然这里有锁,但是可以被安全地消除 掉,在即时编译之后,这段代码就会忽略掉所有的同步而直接执行了。
4.4.8 锁粗化
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放 到这串操作的外面,这样只需要加一次锁即可。
5. Lock
5.1 java.util.concurrent.locks包下常用的类与接口
- Lock和ReadWriteLock是两大锁的根接口,Lock代表实现类是ReentrantLock(可重入锁),ReadWriteLock(读写锁)的代表实现类是ReentrantReadWriteLock。
Lock 接口支持那些语义不同(重入、公平等)的锁规则,可以在非阻塞式结构的上下文(包括 hand-over-hand 和锁重排算法)中使用这些规则。主要的实现是 ReentrantLock。
ReadWriteLock 接口以类似方式定义了一些读取者可以共享而写入者独占的锁。此包只提供了一个实现,即 ReentrantReadWriteLock,因为它适用于大部分的标准用法上下文。但程序员可以创建自己的、适用于非标准要求的实现。 - Condition 接口描述了可能会与锁有关联的条件变量。这些变量在用法上与使用 Object.wait 访问的隐式监视器类似,但提供了更强大的功能。需要特别指出的是,单个 Lock 可能与多个 Condition 对象关联。为了避免兼容性问题,Condition 方法的名称与对应的 Object 版本中的不同。
5.2 synchronized的缺陷
5.2.1 synchronized和lock的不同
- Lock不是Java语言内置的,synchronized是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问;
- Lock和synchronized有一点非常大的不同,采用synchronized不需要用户去手动释放锁,当synchronized方法或者synchronized代码块执行完之后,系统会自动让线程释放对锁的占用;而Lock则必须要用户去手动释放锁,如果没有主动释放锁,就有可能导致出现死锁现象。
5.2.2 synchronized的局限
如果一个代码块被synchronized关键字修饰,当一个线程获取了对应的锁,并执行该代码块时,其他线程便只能一直等待直至占有锁的线程释放锁。事实上,占有锁的线程释放锁一般会是以下三种情况之一:
1. 占有锁的线程执行完了该代码块,然后释放对锁的占有;
2. 占有锁线程执行发生异常,此时JVM会让线程自动释放锁;
3. 占有锁线程进入 WAITING 状态从而释放锁,例如在该线程中调用wait()方法等。
试考虑以下三种情况:
Case 1 :
在使用synchronized关键字的情形下,假如占有锁的线程由于要等待IO或者其他原因(比如调用sleep方法)被阻塞了,但是又没有释放锁,那么其他线程就只能一直等待,别无他法。这会极大影响程序执行效率。因此,就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间 (解决方案:tryLock(long time, TimeUnit unit)) 或者 能够响应中断 (解决方案:lockInterruptibly())),这种情况可以通过 Lock 解决。
Case 2 :
我们知道,当多个线程读写文件时,读操作和写操作会发生冲突现象,写操作和写操作也会发生冲突现象,但是读操作和读操作不会发生冲突现象。但是如果采用synchronized关键字实现同步的话,就会导致一个问题,即当多个线程都只是进行读操作时,也只有一个线程在可以进行读操作,其他线程只能等待锁的释放而无法进行读操作。因此,需要一种机制来使得当多个线程都只是进行读操作时,线程之间不会发生冲突。同样地,Lock也可以解决这种情况 (解决方案:ReentrantReadWriteLock) 。
以上两种情况都是线程阻塞但是不放锁。
Case 3 :
我们可以通过Lock得知线程有没有成功获取到锁 (解决方案:ReentrantLock) ,但这个是synchronized无法办到的。
锁的可见性
5.3 Lock接口实现类的使用
Lock接口有6个方法
// 获取锁
void lock()
// 如果当前线程未被中断,则获取锁,可以响应中断
void lockInterruptibly()
// 返回绑定到此 Lock 实例的新 Condition 实例
Condition newCondition()
// 仅在调用时锁为空闲状态才获取该锁,可以响应中断
boolean tryLock()
// 如果锁在给定的等待时间内空闲,并且当前线程未被中断,则获取锁
boolean tryLock(long time, TimeUnit unit)
// 释放锁
void unlock()
下面来逐个分析Lock接口中每个方法。lock()、tryLock()、tryLock(long time, TimeUnit unit) 和 lockInterruptibly()都是用来获取锁的。unLock()方法是用来释放锁的。newCondition() 返回 绑定到此 Lock 的新的 Condition 实例 ,用于线程间的协作,详细内容请查找关键词:线程间通信与协作。
5.3.1 lock()
Lock中声明了四个方法来获取锁,那么这四个方法有何区别呢?首先,lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。在前面已经讲到,如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此,一般来说,使用Lock必须在try…catch…块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。通常使用Lock来进行同步的话,是以下面这种形式去使用的:
Lock lock = ...;
lock.lock();
try{
//处理任务
}catch(Exception ex){
}finally{
lock.unlock(); //释放锁
}
5.3.2 tryLock() & tryLock(long time, TimeUnit unit)
tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回true;如果获取失败(即锁已被其他线程获取),则返回false,也就是说,这个方法无论如何都会立即返回(在拿不到锁时不会一直在那等待)。
tryLock(long time, TimeUnit unit)方法和tryLock()方法是类似的,只不过区别在于这个方法在拿不到锁时会等待一定的时间,在时间期限之内如果还拿不到锁,就返回false,同时可以响应中断。如果一开始拿到锁或者在等待期间内拿到了锁,则返回true。
一般情况下,通过tryLock来获取锁时是这样使用的:
Lock lock = ...;
if(lock.tryLock()) {
try{
//处理任务
}catch(Exception ex){
}finally{
lock.unlock(); //释放锁
}
}else {
//如果不能获取锁,则直接做其他事情
}
5.3.3 lockInterruptibly()
lockInterruptibly()方法比较特殊,当通过这个方法去获取锁时,如果线程 正在等待获取锁,则这个线程能够 响应中断,即中断线程的等待状态。例如,当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,假若此时线程A获取到了锁,而线程B只有在等待,那么对线程B调用threadB.interrupt()方法能够中断线程B的等待过程。
由于lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外声明抛出 InterruptedException,但推荐使用后者,原因稍后阐述。因此,lockInterruptibly()一般的使用形式如下:
public void method() throws InterruptedException {
lock.lockInterruptibly();
try {
//.....
}
finally {
lock.unlock();
}
}
注意,当一个线程获取了锁之后,是不会被interrupt()方法中断的。因为interrupt()方法只能中断阻塞过程中的线程而不能中断正在运行过程中的线程。因此,当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,那么只有进行等待的情况下,才可以响应中断的。与 synchronized 相比,当一个线程处于等待某个锁的状态,是无法被中断的,只有一直等待下去。
5.3.4 Lock的实现类 ReentrantLock
ReentrantLock,即 可重入锁。ReentrantLock是唯一实现了Lock接口的类,并且ReentrantLock提供了更多的方法。下面通过一些实例学习如何使用 ReentrantLock。
public class LockThread {
Lock lock = new ReentrantLock();
public void lock(String name) {
// 获取锁
lock.lock();
try {
System.out.println(name + " get the lock");
// 访问此锁保护的资源
} finally {
// 释放锁
lock.unlock();
System.out.println(name + " release the lock");
}
}
public static void main(String[] args) {
LockThread lt = new LockThread();
new Thread(() -> lt.lock("A")).start();
new Thread(() -> lt.lock("B")).start();
}
}
从执行结果可以看出,A线程和B线程同时对资源加锁,A线程获取锁之后,B线程只好等待,直到A线程释放锁B线程才获得锁。
总结一下,也就是说Lock提供了比synchronized更多的功能。但是要注意以下几点:
1)synchronized是Java语言的关键字,因此是内置特性,Lock不是Java语言内置的,Lock是一个接口,通过实现类可以实现同步访问。
2)synchronized是在JVM层面上实现的,不但可以通过一些监控工具监控synchronized的锁定,而且在代码执行时出现异常,JVM会自动释放锁定,但是使用Lock则不行,lock是通过代码实现的,要保证锁定一定会被释放,就必须将unLock()放到finally{}中
3)在资源竞争不是很激烈的情况下,Synchronized的性能要优于ReetrantLock,但是在资源竞争很激烈的情况下,Synchronized的性能会下降几十倍,但是ReetrantLock的性能能维持常态。
5.4 RewriteLock
ReadWriteLock 接口只有两个方法:
//返回用于读取操作的锁
Lock readLock()
//返回用于写入操作的锁
Lock writeLock()
ReadWriteLock 维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持,而写入锁是独占的。
【例子】三个线程同时对一个共享数据进行读写
class Queue {
//共享数据,只能有一个线程能写该数据,但可以有多个线程同时读该数据。
private Object data = null;
ReadWriteLock lock = new ReentrantReadWriteLock();
// 读数据
public void get() {
// 加读锁
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " be ready to read data!");
Thread.sleep((long) (Math.random() * 1000));
System.out.println(Thread.currentThread().getName() + " have read data :" + data);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放读锁
lock.readLock().unlock();
}
}
// 写数据
public void put(Object data) {
// 加写锁
lock.writeLock().lock();
try {
System.out.println(Thread.currentThread().getName() + " be ready to write data!");
Thread.sleep((long) (Math.random() * 1000));
this.data = data;
System.out.println(Thread.currentThread().getName() + " have write data: " + data);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
// 释放写锁
lock.writeLock().unlock();
}
}
}
public class ReadWriteLockDemo {
public static void main(String[] args) {
final Queue queue = new Queue();
//一共启动6个线程,3个读线程,3个写线程
for (int i = 0; i < 3; i++) {
//启动1个读线程
new Thread() {
public void run() {
while (true) {
queue.get();
}
}
}.start();
//启动1个写线程
new Thread() {
public void run() {
while (true) {
queue.put(new Random().nextInt(10000));
}
}
}.start();
}
}
}