1.共享带来的问题
一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区
2.Synchronized使用方式
在多线程场景下,对共享资源代码段进行读写操作(必须包含写操作,光读不会有线程安全问题,因为读操作天然具备线程安全特性),可能会出现线程安全问题,我们可以使用Synchronized
锁定共享资源代码段,达到互斥(mutualexclusion
)效果,保证线程安全。
Synchronized
的使用方式有三种:
-
修饰普通函数,监视器锁(
monitor
)便是对象实例(this
) -
修饰静态静态函数,监视器锁(
monitor
)便是对象的Class
实例(每个对象只有一个Class
实例) -
修饰代码块,监视器锁(
monitor
)是指定对象实例
2.1普通函数
在访问权限修饰符与函数返回类型间加上Synchronized
。
/**
多线程场景下,thread与threadTwo两个线程执行incr函数,
incr函数作为共享资源代码段被多线程读写操作,
我们将它称为临界区,为了保证临界区互斥,
使用Synchronized修饰incr函数即可。
*/
public class SyncTest {
private int j = 0;
/**
* 自增方法
*/
public synchronized void incr(){
//临界区代码--start
for (int i = 0; i < 10000; i++) {
j++;
}
//临界区代码--end
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果
System.out.println(syncTest.getJ());
}
}
被synchronized
修饰函数我们简称同步函数,线程执行称同步函数前,需要先获取监视器锁。获取监视器锁成功才能执行同步函数,同步函数执行完后,线程会释放锁并通知唤醒其他线程获取锁,获取锁失败「则阻塞并等待通知唤醒该线程重新获取锁」,同步函数会以this
作为锁,即当前对象,以上面的代码段为例就是syncTest
对象。
2.2静态函数
它使用Synchronized
的方式与普通函数一致,唯一的区别是锁的对象不再是this
,而是Class
对象。
public class SyncTest {
private static int j = 0;
/**
* 自增方法
*/
public static synchronized void incr(){
//临界区代码--start
for (int i = 0; i < 10000; i++) {
j++;
}
//临界区代码--end
}
public static int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
Thread thread = new Thread(() -> SyncTest.incr());
Thread threadTwo = new Thread(() -> SyncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000,如果不使用synchronized修饰,就会导致线程安全问题,输出不确定结果
System.out.println(SyncTest.getJ());
}
}
Java
的静态资源可以直接通过类名调用,静态资源不属于任何实例对象,它只属于Class
对象,每个Class
在J V M
中只有唯一的一个Class
对象,所以同步静态函数会以Class
对象作为锁,后续获取锁、释放锁流程都一致。
2.3代码块
使用代码块可以缩小范围灵活配置。
下面代码中定义了syncDbData
函数,syncDbData
是一个伪同步数据的函数,耗时2
秒,并且逻辑不涉及共享资源读写操作(非临界区),另外还有两个函数incr
与incrTwo
,都是在自增逻辑前执行了syncDbData
函数,只是使用Synchronized
的姿势不同,一个是修饰在函数上,另一个是修饰在代码块上。
public class SyncTest {
private static int j = 0;
/**
* 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。
*/
public void syncDbData() {
System.out.println("db数据开始同步------------");
try {
//同步时间需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db数据开始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--临界区代码
//同步库数据
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
//自增方法
public void incrTwo() {
//同步库数据
syncDbData();
synchronized (this) {
//start--临界区代码
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
}
public int getJ() {
return j;
}
}
public class SyncMain {
public static void main(String[] agrs) throws InterruptedException {
//incr同步方法执行
SyncTest syncTest = new SyncTest();
Thread thread = new Thread(() -> syncTest.incr());
Thread threadTwo = new Thread(() -> syncTest.incr());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是20000
System.out.println(syncTest.getJ());
//incrTwo同步块执行
thread = new Thread(() -> syncTest.incrTwo());
threadTwo = new Thread(() -> syncTest.incrTwo());
thread.start();
threadTwo.start();
thread.join();
threadTwo.join();
//最终打印结果是40000
System.out.println(syncTest.getJ());
}
}
代码块同步方式除了灵活控制范围外,还能做线程间的协同工作,因为Synchronized ()
括号中能接收任何对象作为锁,所以可以通过Object
的wait、notify、notifyAll
等函数,做多线程间的通信协同。
3.Synchronized原理
3.1Mark World
概念
Java
中每个对象由对象头和对象实例数据构成,其中对象头由Mark World
、指向类的指针组成(如果是数组对象,在对象头中还会有‘数组长度’这个数值)。
32位虚拟机的Mark world结构:
64位虚拟机的Mark world结构:
3.2Monitor 原理(重量级锁)
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的 Mark Word 中就被设置指向 Monitor 对象的指针 ,注意只有重量锁才会绑定monitor,轻量锁和偏向锁是不会的。
流程:
1.刚开始时候,monitor中的owner是null。
2.当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一 个 Owner。
3. 在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED (其他线程进入阻塞状态)。
4.Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的。
5.图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,他们调用了比如wait()方法,让出了占用的资源,并且进入waitting(阻塞的一种),等待线程通信唤醒。
3.3反编译看一下,虚拟机字节码
public class SyncTest {
private static int j = 0;
/**
* 同步库数据,比较耗时,代码资源不涉及共享资源读写操作。
*/
public void syncDbData() {
System.out.println("db数据开始同步------------");
try {
//同步时间需要2秒
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("db数据开始同步完成------------");
}
//自增方法
public synchronized void incr() {
//start--临界区代码
//同步库数据
syncDbData();
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
//自增方法
public void incrTwo() {
//同步库数据
syncDbData();
synchronized (this) {
//start--临界区代码
for (int i = 0; i < 10000; i++) {
j++;
}
//end--临界区代码
}
}
public int getJ() {
return j;
}
}
只截取了incr与incrTwo函数内容
public synchronized void incr();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: iconst_0
5: istore_1
6: iload_1
7: sipush 10000
10: if_icmpge 27
13: getstatic #12 // Field j:I
16: iconst_1
17: iadd
18: putstatic #12 // Field j:I
21: iinc 1, 1
24: goto 6
27: return
public void incrTwo();
Code:
0: aload_0
1: invokevirtual #11 // Method syncDbData:()V
4: aload_0
5: dup
6: astore_1
7: monitorenter //获取锁
8: iconst_0
9: istore_2
10: iload_2
11: sipush 10000
14: if_icmpge 31
17: getstatic #12 // Field j:I
20: iconst_1
21: iadd
22: putstatic #12 // Field j:I
25: iinc 2, 1
28: goto 10
31: aload_1
32: monitorexit //正常退出释放锁
33: goto 41
36: astore_3
37: aload_1
38: monitorexit //异步退出释放锁
39: aload_3
40: athrow
41: return
-
在反编译后的结果中,我们发现存在
monitorenter
与monitorexit
指令(获取锁、释放锁)。monitorenter
指令插入到同步代码块的开始位置,monitorexit
指令插入到同步代码块的结束位置,J V M
需要保证每一个monitorenter
都有monitorexit
与之对应。 -
任何对象都有一个监视器锁(
monitor
)关联,线程执行monitorenter
指令时尝试获取monitor
的所有权。-
如果
monitor
的进入数为0
,则该线程进入monitor
,然后将进入数设置为1
,该线程为monitor
的所有者 -
如果线程已经占有该
monitor
,重新进入,则monitor
的进入数加1
-
线程执行
monitorexit
,monitor
的进入数-1,执行过多少次monitorenter
,最终要执行对应次数的monitorexit
-
如果其他线程已经占用
monitor
,则该线程进入阻塞状态,直到monitor
的进入数为0,再重新尝试获取monitor
的所有权
-
4锁升级
Jdk 1.5
以后对Synchronized
关键字做了各种的优化,经过优化后Synchronized
已经变得原来越快了,官方建议使用Synchronized
的原因,具体的优化点如下。
-
锁粗化:多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作
-
锁消除:
Java
虚拟机在JIT
编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析(对象在函数中被使用,也可能被外部函数所引用,称为函数逃逸),去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的时间消耗。 -
锁升级:
Java1.5
以后为了减少获取锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁,Synchronized
的升级顺序是无锁-->偏向锁-->轻量级锁-->重量级锁,只会升级不会降级。
所以注意!!我们讨论的偏向锁、轻量级锁、重量级锁这些名词是针对Synchronized
锁而言的
4.1轻量锁
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以 使用轻量级锁来优化。
轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争 的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢。
假设有两个方法同步块,利用同一个对象加锁 :
4.2 锁膨胀
如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有 竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。
4.3自旋优化
重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步 块,释放了锁),这时当前线程就可以避免阻塞。
-
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
-
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,总之,比较智能。
-
Java 7 之后不能控制是否开启自旋功能。
-
总结:轻量级锁考虑的是竞争锁对象的线程不多,持有锁时间也不长的场景。因为阻塞线程需要
C P U
从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失,所以干脆不阻塞这个线程,让它自旋一段时间等待锁释放。 -
当前线程持有的锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,从而提高性能。轻量级锁的获取主要有两种情况:① 当关闭偏向锁功能时;② 多个线程竞争偏向锁导致偏向锁升级为轻量级锁。
-
无锁状态,存储内容「是否为偏向锁(
0
)」,锁标识位01
-
关闭偏向锁功能时
-
CAS
设置当前线程栈中锁记录的指针到Mark Word
存储内容 -
锁标识位设置为
00
-
执行同步代码或方法
-
释放锁时,还原来
Mark Word
内容
-
-
轻量级锁状态,存储内容「线程栈中锁记录的指针」,锁标识位
00
(存储内容的线程是指"持有轻量级锁的线程")-
CAS
设置当前线程栈中锁记录的指针到Mark Word
存储内容,设置成功获取轻量级锁,执行同步块代码或方法,否则执行下面的逻辑 -
设置失败,证明多线程存在一定竞争,线程自旋上一步的操作,自旋一定次数后还是失败,轻量级锁升级为重量级锁
-
Mark Word
存储内容替换成重量级锁指针,锁标记位10
-
4.4偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。 Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现 这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有 。
即:查看线程ID是否为当前线程,是的话则直接执行,只是在Mark Word里存储当前线程指针,CAS操作都不做。
如果线程ID不是当前线程,则CAS争夺锁,若成功则设置线程ID为自己 失败,则升级为轻量级锁
偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。 这个锁会偏向于第一个获得它的线 程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需 要再进行同步。如果程序中大多数的锁都总是被多个不同的线程访 问,那偏向模式就是多余的
4.5 重量锁
轻量级锁膨胀之后,就升级为重量级锁,重量级锁是依赖操作系统的MutexLock
(互斥锁)来实现的,需要从用户态转到内核态,这个成本非常高。升级为重量级锁时,锁标志位的状态值变为10
,此时Mark Word
中存储内容的是重量级锁的指针,等待锁的线程都会进入阻塞状态。
5.synchronize常见问题
5.1synchronized怎么保证可见性?
-
线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
-
线程加锁后,其它线程无法获取主内存中的共享变量。
-
线程解锁前,必须把共享变量的最新值刷新到主内存中。
5.2synchronized怎么保证有序性?
synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。
所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。
5.3synchronized怎么实现可重入的呢?
synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。
synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。
6.wait和notify原理(用于线程之间通信,但是不推荐)
-
Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
-
BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
-
BLOCKED 线程会在 Owner 线程释放锁时唤醒
-
WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味者立刻获得锁,仍需进入 EntryList 重新竞争
-
注意!!!wait()方法,不会占用时间片,即使它用了时间片,调了wait()后,也会把主动把时间片让出来的,不会占用资源,但是如果是sleep方法,虽然在休眠,但是不会主动让出时间片和占用的资源的