synchronized底层实现
一、并发编程的三个问题
可见性
(1)什么是可见性?
可见性(Visibility)
:是指一个线程对共享变量
进行修改,另一个线程立即得到修改后的最新值。
(2)实例演示
一个线程根据boolean
类型的标记flag
, 进行while
循环,另一个线程改变这个flag变量
的值,另一个线程并不会停止循环。
public class Test01Visibility {
// 多个线程都会访问的数据,我们称为线程的共享数据
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
可见性问题:当一个线程对共享变量
进行了修改,另外的线程并没有立即看到修改后的最新值。
(3)保证可见性
方式一:使用synchronized
加锁
synchronized
保证可见性的原理,执行synchronized
时,会对应lock
原子操作会刷新工作内存中共享变量的值。
public class Test01Visibility {
// 多个线程都会访问的数据,我们称为线程的共享数据
private static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
// 增加对象共享数据的打印,println是同步方法
System.out.println("run = " + run);
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
因为某一个线程进入synchronized
代码块前后,线程会获得锁,清空工作内存,从主内存拷贝共享变量
最新的值到工作内存成为副本
,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。
方式二:volatile
修饰共享变量
每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果一个线程操作了数据并且写入了,其它已经读取的线程的变量副本
就会失效了,需要使用共享数据
进行操作又要再次去主内存中读取了。volatile
保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile
修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。<点击查看volatile关键字的详细使用>
public class Test01Visibility {
// 多个线程都会访问的数据,我们称为线程的共享数据
private static volatile boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
}
});
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
run = false;
System.out.println("时间到,线程2设置为false");
});
t2.start();
}
}
原子性
(1)什么是原子性?
原子性(Atomicity)
:在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行。【一次操作,要么完全成功,要么完全失败】
(2)实例演示
5个线程
各执行1000次 i++
,结果并不能保证共享变量的最终值为5000
。
public class Test02Atomicity {
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
number++;
}
};
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
原子性问题:当一个线程对共享变量
操作到一半时,另外的线程也有可能来操作共享变量
,干扰了前一个线程的操作。
(3)保证原子性
synchronized
保证原子性的原理,synchronized
保证只有一个线程拿到锁,能够进入同步代码块。
public class Test02Atomicity {
private static int number = 0;
public static void main(String[] args) throws InterruptedException {
Runnable increment = () -> {
for (int i = 0; i < 1000; i++) {
synchronized (Test01Atomicity.class) {
number++;
}
}
};
ArrayList<Thread> ts = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Thread t = new Thread(increment);
t.start();
ts.add(t);
}
for (Thread t : ts) {
t.join();
}
System.out.println("number = " + number);
}
}
对number++
增加同步代码块后,保证同一时间只有一个线程操作number++
,就不会出现安全问题。
有序性
(1)什么是有序性
有序性(Ordering)
:是指程序中代码的执行顺序,Java在编译时
和运行时
会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。
(2)实例演示
这里使用jcstress
并发测试工具
https://wiki.openjdk.java.net/display/CodeTools/jcstress
修改pom.xml
文件,添加依赖:
<dependency>
<groupId>org.openjdk.jcstress</groupId>
<artifactId>jcstress-core</artifactId>
<version>${jcstress.version}</version>
</dependency>
有种特殊情况是:在执行线程2时,由于java编译和运行时
的优化打乱了代码顺序,导致ready = true
先执行,这样线程1的执行结果就为0
。
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State public class Test03Orderliness {
int num = 0; boolean ready = false;
// 线程一执行的代码
@Actor public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2执行的代码
@Actor public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
测试:
mvn clean install
java -jar target/jcstress.jar
有序性问题:程序代码在执行过程中的先后顺序,由于Java在编译期以及运行期的优化,导致了代码的执行顺序未必就是开发者编写代码时的顺序。
(3)as-if-serial
语义
不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的。
(4)保证有序性
synchronized
保证有序性的原理,我们加synchronized
后,依然会发生重排序,只不过,我们有同步代码块,可以保证只有一个线程执行同步代码中的代码。
@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "danger")
@State public class Test03Orderliness {
int num = 0;
boolean ready = false;
Object obj = new Object();
// 线程一执行的代码
@Actor public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程2执行的代码
@Actor public void actor2(I_Result r) {
synchronized (obj) {
num = 2;
ready = true;
}
}
}
二、Java内存模型:JMM(JavaMemoryModel)
(1)什么是JMM
?
JMM
:Java内存模型,是java虚拟机
规范中所定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别(注意这个跟JVM
完全不是一个东西)。
(2)具体实现
计算机的内存模型
:
其实早期计算机中cpu
和内存
的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度
,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)
来作为内存与处理器之间的缓冲。
将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)
。
在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory
。
现在开始聊一下JMM
:
Java内存模型(JavaMemoryModel)
描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。
规定
JMM
有以下规定:
-
主内存:主内存是所有线程都共享的,都能访问的。所有的
共享变量
都存储于主内存。 -
工作内存:每一个线程有自己的工作内存,工作内存只存储该线程对
共享变量的副本
。线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量
,不同线程之间也不能直接访问对方工作内存中的变量。
关系
本地内存和主内存的关系:
作用
Java内存模型是一套规范
,描述了Java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节,Java内存模型是对共享数据的可见性、有序性、和原子性
的规则和保障。
具体交互
Java内存模型中定义了以下8种操作来完成,主内存与工作内存之间具体的交互协议
,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
过程:lock -> read -> load -> use -> assign -> store -> write -> unlock
注意:
- 如果对一个变量执行
lock
操作,将会清空工作内存中此变量的值。 - 对一个变量执行
unlock
操作之前,必须先把此变量同步到主内存中。
实现关键字
使用synchronized,volatile
关键字。
三、synchronized
的特性
可重入特性
(1)什么是可重入?
一个线程可以多次执行synchronized
,重复获取同一把锁
。
(2)实例
public class Demo01 {
public static void main(String[] args) {
Runnable sellTicket = new Runnable() {
@Override public void run() {
synchronized (Demo01.class) {
System.out.println("我是run");
test01();
}
}
public void test01() {
synchronized (Demo01.class) {
System.out.println("我是test01")
}
}
};
new Thread(sellTicket).start();
}
}
(3)原理
synchronized
是可重入锁,内部锁对象中会有一个计数器
记录线程获取几次锁啦,在执行完同步代码块时,计数器的数量会-1
,知道计数器的数量为0
,就释放锁。
(4)好处
- 可以避免死锁
- 可以让我们更好的来封装代码
不可中断特性
(1)什么是不可中断?
一个线程获得锁后,另一个线程想要获得锁,必须处于阻塞或等待
状态,如果第一个线程不释放锁,第二个线程会一直阻塞或等待,不可被中断。
(2)实例
synchronized
不可中断:
public class Demo02_Uninterruptible {
private static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
// 1.定义一个Runnable
Runnable run = () -> {
// 2.在Runnable定义同步代码块
synchronized (obj) {
String name = Thread.currentThread().getName();
System.out.println(name + "进入同步代码块");
// 保证不退出同步代码块
try {
Thread.sleep(888888);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
// 3.先开启一个线程来执行同步代码块
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
// 4.后开启一个线程来执行同步代码块(阻塞状态)
Thread t2 = new Thread(run);
t2.start();
// 5.停止第二个线程
System.out.println("停止线程前");
t2.interrupt();
System.out.println("停止线程后");
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
ReentrantLock
不可中断:
public static void test01() throws InterruptedException {
Runnable run = () -> {
String name = Thread.currentThread().getName();
try {
lock.lock();//使用用lock获取锁表示不可中断
System.out.println(name + "获得锁,进入锁执行");
Thread.sleep(88888);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
System.out.println(name + "释放锁");
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
System.out.println("停止t2线程前");
t2.interrupt();
System.out.println("停止t2线程后");
Thread.sleep(1000);
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
ReentrantLock
可中断:
public static void test01() throws InterruptedException {
Runnable run = () -> {
String name = Thread.currentThread().getName();
boolean b = false;
try {
b = lock.tryLock(3, TimeUnit.SECONDS);//使用tryLock表示等待直到时间会中断
if (b) {
System.out.println(name + "获得锁,进入锁执行");
Thread.sleep(88888);
} else {
System.out.println(name + "在指定时间没有得到锁做其他操作");
}
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
if (b) {
lock.unlock();
System.out.println(name + "释放锁");
}
}
};
Thread t1 = new Thread(run);
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(run);
t2.start();
System.out.println("停止t2线程前");
t2.interrupt();
System.out.println("停止t2线程后");
Thread.sleep(1000);
System.out.println(t1.getState());
System.out.println(t2.getState());
}
}
(3)原理
不可中断是指,当一个线程获得锁后,另一个线程一直处于阻塞或等待
状态,前一个线程不释放锁,后一个线程会一直阻塞或等待,不可被中断。
(4)两种方式区别
synchronized
属于不可被中断;Lock
的lock方法
是不可中断的;Lock
的tryLock方法
是可中断的。
四、 synchronized
原理
(1)同步代码块
通过javap -p -v -c
对class
字节码文件进行反编译,出现下面效果:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_0
1: istore_1
2: getstatic #2 // Field obj:Ljava/lang/Object;
5: dup 6: astore_2
7: monitorenter //获取锁时
8: iinc 1, 1
11: aload_2
12: monitorexit //释放锁时
13: goto 21
16: astore_3
17: aload_2
18: monitorexit //发生异常会自动释放锁
19: aload_3
20: athrow
21
monitor:
每一个对象都会和一个监视器monitor
关联。监视器被占用时会被锁住,其他线程无法来获取该monitor
。 当JVM
执行某个线程的某个方法内部的monitorenter
时,它会尝试去获取当前对象对应的monitor
的所有权。其过程如下:
- 若
monior
的进入数为0,线程可以进入monitor
,并将monitor
的进入数置为1。当前线程成为monitor
的owner
(所有者); - 若线程已拥有
monitor
的所有权,允许它重入monitor
,则进入monitor
的进入数加1 ; - 若其他线程已经占有
monitor
的所有权,那么当前尝试获取monitor
的所有权的线程会被阻塞,直到monitor
的进入数变为0,才能重新尝试获取monitor
的所有权。
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
。
总结:synchronized
同步语句块的实现使⽤的是 monitorenter
和monitorexit
指令,其中monitorenter
指令指向同步代码块的开始位置,monitorexit
指令则指明同步代码块的结束位置。 当执⾏monitorenter
指令时,线程试图获取锁也就是获取monitor
(monitor对象存在于每个Java对象的对象头中,synchronized 锁
便是通过这种⽅式获取锁的,也是为什么Java中任意对象可以作为锁的原因)的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执⾏monitorexit
指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外⼀个线程释放为⽌。
(2)同步方法
同样,进行反编译:
同步方法在反汇编后,会增加 ACC_SYNCHRONIZED
修饰。会隐式调用monitorenter
和monitorexit
。在执行同步方法前会调用monitorenter
,在执行完同步方法后会调用monitorexit
。【JVM 通过该 ACC_SYNCHRONIZED
访问标志来辨别⼀个⽅法是否声明为同步⽅法,从⽽执⾏相应的同步调⽤。】
(3)总结
通过javap反汇编
可以看到synchronized
使用编程了monitorentor
和monitorexit
两个指令.每个锁对象都会关联一个monitor
(监视器,它才是真正的锁对象),它内部有两个重要的成员变量owner
会保存获得锁的线程,recursions
会保存线程获得锁的次数,当执行到monitorexit
时,recursions
会-1
,当计数器减到0
时这个线程就会释放锁。
(4)synchronized
与Lock
的区别
synchronized
是关键字,而Lock
是一个接口。synchronized
会自动释放锁,而Lock
必须手动释放锁。synchronized
是不可中断的,Lock
可以中断也可以不中断【通过lock.tryLock()
实现】。Lock
可以知道线程有没有拿到锁,而synchronized
不能。synchronized
能锁住方法和代码块,而Lock
只能锁住代码块。Lock
可以使用读锁提高多线程读效率。synchronized
是非公平锁,ReentrantLock
可以控制是否是公平锁【通过构造器ReentrantLock(boolean fair)
】。
五、 通过JVM
源码分析synchronized
monitor
监视器锁
monitor
主要结构如下:
ObjectMonitor() {
_header = NULL;
_count = 0;
_waiters = 0,
_recursions = 0; // 线程的重入次数
_object = NULL; // 存储该monitor的对象
_owner = NULL; // 标识拥有该monitor的线程
_WaitSet = NULL; // 处于wait状态的线程,会被加入到
_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL;
_succ = NULL;
_cxq = NULL; // 多线程竞争锁时的单向列表
FreeNext = NULL;
_EntryList = NULL; // 处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0;
_SpinClock = 0;
OwnerIsThread = 0;
}
_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方法而被阻塞的线程会被放在该队列中。
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
或EntryLis
t中获取头节点,通过ObjectMonitor::ExitEpilog
方法唤醒该节点封装的线程,唤醒操作最终由unpark
完成。
monitor
是重量级锁
执行同步代码块,没有竞争到锁的对象会park()
被挂起,竞争到锁的线程会unpark()
唤醒。这个时候就会存在操作系统用户态
和内核态
的转换,这种切换会消耗大量的系统资源。所以synchronized
是Java语言中是一个重量级(Heavyweight)
的操作。
六、 JDK6 synchronized
优化
CAS
CAS
:Compare And Swap
(比较相同再交换),CAS
可以将比较和交换转换为原子操作,这个原子操作直接由CPU
保证。CAS
可以保证共享变量赋值时的原子操作。CAS
操作依赖3个值:内存中的值V,旧的预估值X,要修改的新值B,如果旧的预估值X等于内存中的值V,就将新的值B保存到内存中。
乐观锁
和悲观锁
悲观锁
从悲观的角度出发:
总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞。因此synchronized我们也将其称之为悲观锁。JDK中的ReentrantLock
也是一种悲观锁。性能较差!
乐观锁
从乐观的角度出发:
总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,就算改了也没关系,再重试即可。所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去修改这个数据,如何没有人修改则更新,如果有人修改则重试。
CAS
这种机制可以将其称之为乐观锁。综合性能较好!
CAS
获取共享变量时,为了保证该变量的可见性,需要使用volatile
修饰。结合CAS
和volatile
可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。
因为没有使用
synchronized
,所以线程不会陷入阻塞,这是效率提升的因素之一。但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。
对象布局
对象
在内存中的布局分为三块区域:对象头、实例数据和对齐填充
:
对象头
由两部分组成:一部分用于存储自身的运行时数据,称之为 Mark Word
,另外一部分是 类型指针 ,及对象指向它的类元数据的指针。
Mark Word
Mark Word
用于存储对象自身的运行时数据,如哈希码(HashCode)
、GC
分代年龄、锁状态标志、线程持有的锁、偏向线程ID
、偏向时间戳等等,占用内存大小与虚拟机位长一致。
64位虚拟机
下,Mark Word
结构:
klass pointer
用于存储对象的类型指针,该指针指向它的类元数据,JVM
通过这个指针确定对象是哪个类的
实例。。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
在64位系统中,Mark Word = 8 bytes
,类型指针 = 8bytes
,对象头 = 16 bytes
= 128bits
;
实例数据
类中定义的成员变量。
对齐填充
仅仅起着占位符的作用,由于HotSpot VM
的自动内存管理系统要求对象起始地址必须是8字节的整数倍
,换句话说,就是对象的大小必须是8字节的整数倍。而对象头正好是8字节的倍数,因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。
总结:
Java对象由3部分组成:
对象头,实例数据,对齐数据
对象头分成两部分:
Mark World + Klass pointer
JDK1.6
对锁的实现引⼊了⼤量的优化,如偏向锁、轻量级锁、⾃旋锁、适应性⾃旋锁、锁消除、锁粗化等技术来减少锁操作的开销。锁主要存在四种状态,依次是:⽆锁状态、偏向锁状态、轻量级锁状态、重量级锁状态
,他们会随着竞争的激烈⽽逐渐升级。注意锁可以升级不可降级
,这种策略是为了提⾼获得锁和释放锁的效率。
(1)偏向锁
偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”,它的意思是这个锁会偏向于第一个获得它的线程
,会在对象头存储锁偏向的线程ID
,以后该线程进入和退出同步块时只需要检查是否为偏向锁[1]、锁标志位[01]以及ThreadID
即可。
不过一旦出现多个线程竞争时必须撤销偏向锁
,所以撤销偏向锁消耗的性能必须小于之前节省下来的CAS原子操作
的性能消耗,不然就得不偿失了。
偏向锁原理:
当线程第一次访问同步块并获取锁时,偏向锁处理流程如下:
虚拟机将会把对象头中的标志位设为“
01
”,即偏向模式。同时使用
CAS操作
把获取到这个锁的线程的ID
记录在对象的Mark Word
之中 ,如果CAS
操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作,偏向锁的效率高。
如果此时多个线程竞争,则需撤销偏向锁,流程如下:
偏向锁的撤销动作必须等待
全局安全点
。
暂停
拥有偏向锁的线程,判断锁对象是否处于被锁定状态 。撤销偏向锁,恢复到
无锁
(标志位为 01)或轻量级锁
(标志位为 00)的状态
注意:
偏向锁在
Java 6
之后是默认启用的,但在应用程序启动几秒钟之后才激活,可以使用- XX:BiasedLockingStartupDelay=0
参数关闭延迟,如果确定应用程序中所有锁通常情况下处于竞争状态,可以通过XX:-UseBiasedLocking=false
参数关闭偏向锁。
小结
偏向锁是在只有一个线程
执行同步块时进一步提高性能,适用于一个线程反复获得同一锁的情况。偏向锁可以 提高带有同步但无竞争
的程序性能。如果此时有不同线程访问,偏向模式不再适合。
(2)轻量级锁
在多线程交替执行同步块
的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
轻量级锁原理:
当关闭偏向锁功能
或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁
,则会尝试获取轻量级锁,其步骤如下:
判断当前对象是否处于无锁状态
(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
中,如果成功,则说明释放锁成功。- 如果
CAS操作
替换失败,说明有其他线程尝试获取该锁,则需要将轻量级锁需要膨胀升级为重量级锁。
小结
在多线程交替执行同步块
的情况下,可以避免重量级锁引起的性能消耗。但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁。
(3)自旋锁
monitor
实现锁的时候会阻塞和唤醒线程,线程的阻塞和唤醒需要CPU从用户态
转为核心态
,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作,这些操作给系统的并发性能带来了很大的压力。并且如果共享数据的锁定状态只会持续很短的一段时间,为了这段时间阻塞和唤醒线程并不值得。此时让后面请求锁的那个线程“稍等一下”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。
JDK 6中
自旋锁默认为开启状态
,自旋等待不能代替阻塞,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间
的,因此,如果锁被占用的时间很短,自旋等待的效果就会非常好,反之,如果锁被占用的时间很长。那么自旋的线程只会白白消耗处理器资源,而不会做任何有用的工作,反而会带来性能上的浪费。自旋等待的时间必须要有一定的限度,如果在多线程交替执行同步块的情况下,可以避免重量级锁引起的性能消耗。 自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程了。自旋次数的默认值是10次
,用户可以使用参数-XX : PreBlockSpin
来更改。
(4)适应性自旋锁
JDK 6
中引入了自适应的自旋锁。自适应意味着自旋的时间不再固定了
,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定
。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间
,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越准确,虛拟机就会变得越来越“聪明
”了。
(5)锁消除
锁消除是指虚拟机即时编译器(JIT)
在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁
进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当做栈上数据对待,认为它们是线程私有的,同步加锁自然就无须进行,变量是否逃逸,对于虚拟机来说需要使用数据流分析来确定。
(6)锁粗化
原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小
,只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁。大部分情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁
,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗.
锁粗化:
JVM会探测到一连串细小的操作都使用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。
七、 平时写代码如何对synchronized
优化
(1)减少synchronized
的范围
同步代码块中尽量短,减少同步代码块中代码的执行时间,减少锁的竞争。
(2)降低synchronized
锁的粒度
将一个锁拆分为多个锁提高并发度。
(3)读写分离
读取时不加锁,写入和删除时加锁。如ConcurrentHashMap,CopyOnWriteArrayList和ConyOnWriteSet