由浅深入理解java多线程,java并发,synchronized实现原理及线程锁机制
[TOC]
多进程是指操作系统能同时运行多个任务(程序)。
多线程是指在同一程序中有多个顺序流在执行。
一,线程的生命周期
[图片上传失败...(image-370bc0-1635087555179)]
-
新建状态:
使用 new 关键字和 Thread 类或其子类建立一个线程对象后,该线程对象就处于新建状态。它保持这个状态直到程序 start() 这个线程。
-
就绪状态:
当线程对象调用了start()方法之后,该线程就进入就绪状态。就绪状态的线程处于就绪队列中,要等待JVM里线程调度器的调度。
-
运行状态:
如果就绪状态的线程获取 CPU 资源,就可以执行 run(),此时线程便处于运行状态。处于运行状态的线程最为复杂,它可以变为阻塞状态、就绪状态和死亡状态。
-
阻塞状态:
如果一个线程执行了sleep(睡眠)、suspend(挂起)等方法,失去所占用资源之后,该线程就从运行状态进入阻塞状态。在睡眠时间已到或获得设备资源后可以重新进入就绪状态。可以分为三种:
- 等待阻塞:运行状态中的线程执行 wait() 方法,使线程进入到等待阻塞状态。
- 同步阻塞:线程在获取 synchronized 同步锁失败(因为同步锁被其他线程占用)。
- 其他阻塞:通过调用线程的 sleep() 或 join() 发出了 I/O 请求时,线程就会进入到阻塞状态。当sleep() 状态超时,join() 等待线程终止或超时,或者 I/O 处理完毕,线程重新转入就绪状态。
-
死亡状态:
一个运行状态的线程完成任务或者其他终止条件发生时,该线程就切换到终止状态。
二,线程的调度
调整线程优先级
Java线程有优先级,优先级高的线程会获得较多的运行机会。
线程睡眠
Thread.sleep(long millis)方法,使线程转到阻塞状态。millis参数设定睡眠的时间,以毫秒为单 位。当睡眠结束后,就转为就绪(Runnable)状态。sleep()平台移植性好。
线程等待
Object类中的wait()方法,导致当前的线程等待,直到其他线程调用此对象的 notify() 方法或 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。 notifyAll() 唤醒方法。这个两个唤醒方法也是Object类中的方法,行为等价于调用 wait(0) 一样。
线程让步
Thread.yield() 方法,暂停当前正在执行的线程对象,把执行机会让给相同或者更高优先级的线 程。
线程加入
join()方法,等待其他线程终止。在当前线程中调用另一个线程的join()方法,则当前线程转入阻 塞状态,直到另一个进程运行结束,当前线程再由阻塞转为就绪状态。
线程唤醒
Object类中的notify()方法,唤醒在此对象监视器上等待的单个线程。如果所有线程都在此对象上等待,则会选择唤醒其中一个线程。选择是任意性的,并在对实现做出决定时发生。线程通过调用其中一个 wait 方法,在对象的监视器上等待。 直到当前的线程放弃此对象上的锁定,才能继续执行被唤醒的线程。被唤醒的线程将以常规方式与在该对象上主动同步的其他所有线程进行竞争;例如,唤醒的线程在作为锁定此对象 的下一个线程方面没有可靠的特权或劣势。类似的方法还有一个notifyAll(),唤醒在此对象监视器上等待的所有线程。
三,创建多线程的方式
1,通过实现Runnable接口
//
public class T3 implements Runnable {
String a;
//构造方法
public T3(String a) {
this.a = a;
}
public void run() {
System.out.println(a);
}
}
//开启了两个线程,实例化了两个对象,但是现在还没有做数据共享的验证
public static void main(String[] args) {
new Thread(new T3("上海")).start();
new Thread(new T3("北京")).start();
}
使用接口,在启动的多线程的时候,需要先通过 Thread 类的构造方法 Thread(Runnable target) 构造出对象,然后调用 Thread 对象的 start() 方法来运行多线程代码。
输出结果:
结果1 结果2
北京 上海
上海 北京
2,通过继承Thread类
//
public class T1 extends Thread {
String a;
//构造方法
public T1(String a) {
this.a = a;
}
public void run() {
System.out.println(a);
}
}
//开启了两个线程,实例化了两个对象,但是现在还没有做数据共享的验证
public class T2 {
public static void main(String[] args) {
new T1("上海").start();
new T1("北京").start();
}
}
输出结果:
结果1 结果2
北京 上海
上海 北京
四,多线程间的数据共享
1,Runnable接口实现多线程的数据共享
//写法1
public class T3 implements Runnable {
int b = 10;
String a;
public T3(String a) {
this.a = a;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(a + b--);
}
}
}
//写法2
public class T3 implements Runnable {
int b = 10;
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + b--);
}
}
}
//开启了两个线程,实例化了1个对象
public class T4 {
public static void main(String[] args) {
T3 t3 = new T3();
new Thread(t3, "上海").start();
new Thread(t3, "北京").start();
}
}
两个线程,一起操作同一个数值,每个线程各操作5次,未出现重复的数值,实现数据共享。部分输出结果为:
输出结果1 | 输出结果2 | 输出结果3 | 输出结果4 | 输出结果5 |
---|---|---|---|---|
上海10 | 上海10 | 上海10 | 上海10 | 北京9 |
上海9 | 上海9 | 北京9 | 北京9 | 北京8 |
上海8 | 上海8 | 北京7 | 北京7 | 北京7 |
上海6 | 上海7 | 北京6 | 北京6 | 北京6 |
上海5 | 上海6 | 北京5 | 上海8 | 北京5 |
北京7 | 北京5 | 北京4 | 上海4 | 上海10 |
北京4 | 北京4 | 上海8 | 上海3 | 上海4 |
北京3 | 北京3 | 上海3 | 北京5 | 上海3 |
北京2 | 北京2 | 上海2 | 上海2 | 上海2 |
北京1 | 北京1 | 上海1 | 北京1 | 上海1 |
2,Thread类实现多线程的数据共享
不方便做到
//
public class T1 extends Thread {
int b = 10;
String a;
public T1(String a) {
this.a = a;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(a + b--);
}
}
}
//开启了两个线程,实例化了2个对象
public class T2 {
public static void main(String[] args) {
new T1("上海").start();
new T1("北京").start();
}
}
输出结果
两个线程,一起操作同一个数值,每个线程各操作5次,出现了重复的数值,未实现数据共享。部分输出结果为:
输出结果1 | 输出结果2 | 输出结果3 | 输出结果4 | 输出结果5 |
---|---|---|---|---|
上海10 | 北京10 | 上海10 | 上海10 | 上海10 |
上海9 | 北京9 | 北京10 | 上海9 | 北京10 |
上海8 | 北京8 | 上海9 | 上海8 | 北京9 |
上海7 | 北京7 | 上海8 | 北京10 | 上海9 |
上海6 | 北京6 | 北京9 | 北京9 | 北京8 |
北京10 | 上海10 | 北京8 | 北京8 | 上海8 |
北京9 | 上海9 | 北京7 | 北京7 | 北京7 |
北京8 | 上海8 | 北京6 | 北京6 | 上海7 |
北京7 | 上海7 | 上海7 | 上海7 | 北京6 |
北京6 | 上海6 | 上海6 | 上海6 | 上海6 |
总结
实现 Runnable 接口比继承 Thread 类所具有的优势:
1):适合多个相同的程序代码的线程去处理同一个资源
2):可以避免 java 中的单继承的限制
五,synchronized实现多线程数据共享
当存在多个线程操作共享数据时,需要保证同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再进行。
当两个并发线程访问同一个对象中的 synchronized 代码块时,在同一时刻只能有一个线程得到执行,另一个线程受阻塞,必须等待当前线程执行完这个代码块以后才能执行该代码块。此时线程是互斥的,因为在执行代码块时会锁定当前的对象,只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象。
1,修饰实例方法
通过Runnable接口
//
public class T3 implements Runnable {
int b = 10;
public synchronized int aaa() {
return b--;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + aaa());
}
}
}
//开启了两个线程,实例化了1个对象
public class T4 {
public static void main(String[] args) {
T3 t3 = new T3();
new Thread(t3, "上海").start();
new Thread(t3, "北京").start();
}
}
输出结果;参照线程间数据共享的Runnable接口的输出结果。可实现数据共享
Thread类
public class T1 extends Thread {
int b = 10;
String a;
public T1(String a) {
this.a = a;
}
public synchronized int aaa() {
return b--;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(a + aaa());
}
}
}
//开启了两个线程,实例化了2个对象
public class T2 {
public static void main(String[] args) {
new T1("上海").start();
new T1("北京").start();
}
}
输出结果;参照线程间数据共享的Thread类的输出结果。没有实现数据共享
如果是一个线程 A 需要访问实例对象 obj1 的 synchronized 方法 f1(当前对象锁是obj1),另一个线程 B 需要访问实例对象 obj2 的 synchronized 方法 f2(当前对象锁是obj2),这样是允许的,因为两个实例对象锁并不同相同, 此时如果两个线程操作数据并非共享的。
虽然我们使用synchronized修饰了 aaa 方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁,因此t1和t2都会进入各自的对象锁,也就是说t1和t2线程使用的是不同的锁,因此线程安全是无法保证的。
2,修饰静态方法
通过Runnable接口
//
public class T3 implements Runnable {
static int b = 10;
public static synchronized int aaa() {
return b--;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + aaa());
}
}
}
//开启了两个线程,实例化了1个对象
public class T4 {
public static void main(String[] args) {
T3 t3 = new T3();
new Thread(t3, "上海").start();
new Thread(t3, "北京").start();
}
}
输出结果;参照线程间数据共享的Runnable接口的输出结果。可实现数据共享
Thread类
public class T1 extends Thread {
static int b = 10;
String a;
public T1(String a) {
this.a = a;
}
public static synchronized int aaa() {
return b--;
}
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(a + aaa());
}
}
}
//开启了两个线程,实例化了2个对象
public class T2 {
public static void main(String[] args) {
new T1("上海").start();
new T1("北京").start();
}
}
输出结果;参照线程间数据共享的Runnable接口的输出结果。可实现数据共享
synchronized作用于静态的 aaa 方法,这样的话,对象锁就当前类对象,由于无论创建多少个实例对象,但对于的类对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。
3,修饰同步代码块
能缩小代码段的范围就尽量缩小,能在代码段上加同步就不要再整个方法上加同步。这叫减小锁的粒度,使代码更大程度的并发。锁的代码段太长了,别的线程就要等很久,等的花儿都谢了。
通过Runnable接口
//
public class T3 implements Runnable {
int b = 10;
public void run()
synchronized (this) {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + aaa());
}
}
}
}
public class T4 {
public static void main(String[] args) {
T3 t3 = new T3();
new Thread(t3, "上海").start();
new Thread(t3, "北京").start();
}
}
4,总结
-
start()方法的调用后并不是立即执行多线程代码,而是使得该线程变为可运行态(Runnable),什么时候运行是由操作系统决定的。
-
请记住,上下文的切换开销也很重要,如果你创建了太多的线程,CPU 花费在上下文的切换的时间将多于执行程序的时间!
- 没有 synchronized 关键字的默认情况。如线程间数据共享一节中并没有用该关键字。
- 实例化多个对象,也就存在多个对象锁,每个线程用不同的对象锁,数据自然无法共享。
- 不管实例化多少个对象,如果synchronized作用于静态方法,由于静态的特殊性,该对象只会有一个,那么在这样的情况下对象锁又是唯一的。
六,synchronized实现原理
1,synchronized修饰后的字节码
上述synchronized主要是了解数据共享的,其字节码并不直观看锁相关的,另外写了个如下所示;
public class T5 {
//修饰方法
public synchronized void aaa(){
}
//修饰静态方法
public static synchronized void bbb(){
}
//修饰类
public void ccc(){
synchronized (T5.class){
}
}
//修饰this
public void ddd(){
synchronized (this){
}
}
}
window下取其字节码内容
image-20211020195414783javac T5.java 编译生成class文件
javap -v -p -s -sysinfo -constants T5.class ,使用javap 工具查看生成的class文件
Classfile /D:/Test/Java/src/com/lgx/test/T5.class
Last modified 2021-10-20; size 549 bytes
MD5 checksum f3500e41224be759d110519587593b09
Compiled from "T5.java"
public class com.lgx.test.T5
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #3.#18 // java/lang/Object."<init>":()V
#2 = Class #19 // com/lgx/test/T5
#3 = Class #20 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 aaa
#9 = Utf8 bbb
#10 = Utf8 ccc
#11 = Utf8 StackMapTable
#12 = Class #19 // com/lgx/test/T5
#13 = Class #20 // java/lang/Object
#14 = Class #21 // java/lang/Throwable
#15 = Utf8 ddd
#16 = Utf8 SourceFile
#17 = Utf8 T5.java
#18 = NameAndType #4:#5 // "<init>":()V
#19 = Utf8 com/lgx/test/T5
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/Throwable
{
public com.lgx.test.T5();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public synchronized void aaa();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 6: 0
public static synchronized void bbb();
descriptor: ()V
flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
Code:
stack=0, locals=0, args_size=0
0: return
LineNumberTable:
line 9: 0
public void ccc();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #2 // class com/lgx/test/T5
2: dup
3: astore_1
4: monitorenter
5: aload_1
6: monitorexit
7: goto 15
10: astore_2
11: aload_1
12: monitorexit
13: aload_2
14: athrow
15: return
Exception table:
from to target type
5 7 10 any
10 13 10 any
LineNumberTable:
line 12: 0
line 13: 5
line 14: 15
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 10
locals = [ class com/lgx/test/T5, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
public void ddd();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: dup
2: astore_1
3: monitorenter
4: aload_1
5: monitorexit
6: goto 14
9: astore_2
10: aload_1
11: monitorexit
12: aload_2
13: athrow
14: return
Exception table:
from to target type
4 6 9 any
9 12 9 any
LineNumberTable:
line 17: 0
line 18: 4
line 19: 14
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 9
locals = [ class com/lgx/test/T5, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
}
SourceFile: "T5.java"
由字节码可知,当修饰方法时,JVM采用 ACC_SYNCHRONIZED
标记符来实现同步。 当修饰类时,JVM采用monitorenter、monitorexit两个指令来实现同步。(在字节码里面可以看见在修饰类时,有Exception table,这是因为,JVM会自动在synchronized代码块中加入异常捕获,从而保证代码抛出异常时,仍能够释放当前线程占用的锁,避免出现死锁现象。)
在synchronized修饰方法时是添加ACC_SYNCHRONIZED
标识。方法级同步是隐式执行的,作为方法调用和返回的一部分。 同步方法在运行时常量池的 method_info 结构中通过 ACC_SYNCHRONIZED 标志进行区分,该标志由方法调用指令检查。 当调用设置了 ACC_SYNCHRONIZED 的方法时,执行线程进入监视器(monitor),调用方法本身,并退出monitor,无论方法调用是正常完成还是突然完成。 在执行线程拥有monitor期间,没有其他线程可以进入它。 如果在调用同步方法过程中抛出异常并且同步方法没有处理该异常,则在异常重新抛出同步方法之前,该方法的monitor会自动退出。
在synchronized修饰类时是通过monitorenter、monitorexit指令。 当且仅当monitor有所有者时,monitor才被锁定。 执行monitorenter 的线程尝试获得与objectref 关联的monitor的所有权,如下所示:
- 如果与objectref 关联的monitor的条目计数为零,则该线程进入monitor并将其条目计数设置为1,然后该线程是monitor的所有者。
- 如果线程已经拥有与 objectref 关联的monitor,它会重新进入monitor,增加其条目计数。
- 如果另一个线程已经拥有与 objectref 关联的monitor,线程会阻塞,直到monitor的条目计数为零,然后再次尝试获得所有权
同理,执行monitorexit 的线程必须是与objectref 引用的实例关联的monitor的所有者。该线程递减与objectref 关联的monitor的入口计数,如果结果条目计数的值为零,则线程退出monitor并且不再是其所有者。
在了解monitor之前,还需先大概了解对象头这个概念。
2,对象头
在hotspot虚拟机中,对象在内存的分布分为3个部分:对象头,实例数据,和对齐填充。
image-20211021134521978-
实例变量:存放类的属性数据信息。 包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
-
填充数据:用于保证对象8字节对齐。 由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。
-
对象头:jvm采用2个字宽(Word)存储对象头,若对象为数组则采用3个字宽来存储。在32位虚拟机中1字宽等于4字节,64位虚拟机中1字宽等于8字节。synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头,如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度,其结构说明如下表:
长度 | 头对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashCode、锁信息或分代年龄或GC标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。 |
32/32bit | Array length | 数组的长度(若当前对象为数组) |
由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据。64位JVM下,如下所示;
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit 锁标志位 |
---|---|---|---|---|
无锁状态 | 对象HashCode | 对象分代年龄 | 0 | 01 |
它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
Mark Wordç�¶æ��å��å��monitor对象存在于每个Java对象的对象头中,synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因。
3,monitor
指向互斥量的指针指向的就是monitor对象的起始地址。在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下
ObjectMonitor() {
_header = NULL;
_count = 0; //记录个数
_waiters = 0, //等待线程数
_recursions = 0; //重入次数
_object = NULL;//存储该monitor的对象
_owner = NULL;//指向获得monitor的ObjectWaiter对象
_WaitSet = NULL; //处于wait状态的线程,会被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;//多线程竞争锁时的单向列表
FreeNext = NULL ;
_EntryList = NULL ; //处于等待锁block状态的线程,会被加入到该列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
}
ObjectMonitor中有两个队列,WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
如下图所示,一个线程通过1号门进入Entry Set(入口区),如果在入口区没有线程等待,那么这个线程就会获取监视器成为监视器的Owner,然后执行监视区域的代码。如果在入口区中有其它线程在等待,那么新来的线程也会和这些线程一起等待。线程在持有监视器的过程中,有两个选择,一个是正常执行监视器区域的代码,释放监视器,通过5号门退出监视器;还有可能等待某个条件的出现,于是它会通过3号门到Wait Set(等待区)休息,直到相应的条件满足后再通过4号门进入重新获取监视器再执行。
当一个线程释放监视器时,在入口区和等待区的等待线程都会去竞争监视器,如果入口区的线程赢了,会从2号门进入;如果等待区的线程赢了会从4号门进入。只有通过3号门才能进入等待区,在等待区中的线程只有通过4号门才能退出等待区,也就是说一个线程只有在持有监视器时才能执行wait操作,处于等待的线程只有再次获得监视器才能退出等待状态。
image-20211021143934832monitor并不是随着对象创建而创建的。而是每个线程都存在两个ObjectMonitor对象列表,分别为free和used列表;同时jvm中也维护着global locklist。当线程需要ObjectMonitor对象时,首先从自身的free表中申请,若存在则使用,若不存在则从global list中申请。
monitor是线程私有的数据结构,每一个线程都有一个可用monitor列表,同时还有一个全局的可用列表,monitor的内部如下所示,
img-
Owner:初始时为NULL表示当前没有任何线程拥有该monitor,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL;
-
EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor失败的线程。
-
RcThis:表示blocked或waiting在该monitor上的所有线程的个数。
-
Nest:用来实现重入锁的计数。
-
HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。
-
Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值:0表示没有需要唤醒的线程,1表示要唤醒一个继任线程来竞争锁。
4,小结
JVM 是通过进入、退出 对象监视器(Monitor) 来实现对方法、同步块的同步的,而对象监视器的本质依赖于底层操作系统的 互斥锁(Mutex Lock) 实现。具体实现是在编译之后在同步方法调用前加入一个monitor.enter
指令,在退出方法和异常处插入monitor.exit
的指令。对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程monitor.exit
之后才能尝试继续获取锁。
当执行monitorenter
指令时,线程试图获取锁也就是获取monitor的持有权。当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行monitorexit
指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。
从synchronized的特点中可以看到它是一种重量级锁,会涉及到操作系统状态的切换影响效率,所以JDK1.6中对synchronized进行了各种优化,为了能减少获取和释放锁带来的消耗引入了偏向锁和轻量锁。
七,锁机制
随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。
Mark Word中的数据随着锁标志位的变化而变化,如下
mark1,偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段。经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是被同一线程多次获得,因此为了减少这同一线程获取锁的代价而引入偏向锁(看来社会上的二八法则也存在于这里)。
偏向锁的获取:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解释,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果是,则直接获得锁,执行同步块;如果不是,则使用CAS操作更改线程ID,更改成功获得锁,更改失败开始撤销偏向锁。
偏向锁的释放:偏向锁只有存在锁竞争的情况下才会释放。撤销偏向锁需要等待全局安全点(在这个时间点上没有正在执行的字节码),首先暂停拥有偏向锁的线程,然后检查此线程是否活着,如果线程不处于活动状态,则转成无锁状态;如果还活着,升级为轻量级锁。下图线程1展示了偏向锁获取的过程,线程2展示了偏向锁撤销的过程。
mark偏向锁的关闭:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0。如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态。
对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。
2,轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁并不是用来代替重量级锁的,它的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用产生的性能消耗。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。
轻量锁的获取:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
轻量锁的释放:轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。下图是两个线程同时争夺锁,导致锁膨胀的流程图。
mark3,重量级锁
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。
4,小结
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 | 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块场景(只有一个线程进入临界区) |
轻量级锁 | 竞争的线程不会阻塞,提高了程序的响应速度 | 如果始终得不到索竞争的线程,使用自旋会消耗CPU | 追求响应速度,同步块执行速度非常快(多个线程交替进入临界区) |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度较慢(多个线程同时进入临界区) |
八,拓展
1,CAS操作
使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。而CAS操作(又称为无锁操作)是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。那么,如果出现冲突了怎么办?无锁操作是使用CAS(compare and swap)又叫做比较交换来鉴别线程是否出现冲突,出现冲突就重试当前操作直到没有冲突为止。
CAS包含三个值:V 内存地址存放的实际值;O 预期的值(旧值);N 更新的新值。当V和O相同时,也就是说旧值和内存中实际的值相同表明该值没有被其他线程更改过,即该旧值O就是目前来说最新的值了,自然而然可以将新值N赋值给V。反之,V和O不相同,表明该值已经被其他线程改过了则该旧值O不是最新版本的值了,所以不能将新值N赋给V,返回V即可。当多个线程使用CAS操作一个变量是,只有一个线程会成功,并成功更新,其余会失败。失败的线程会重新尝试,当然也可以选择挂起线程。
简单来说,就是CPU去更新一个值,但如果想改的值不再是原来的值,操作就失败,因为很明显,有其它操作先改变了这个值。就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS 操作是基于共享数据不会被修改的假设,采用了类似于数据库的commit-retry 的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。
2,CAS问题
1,ABA问题
因为CAS会检查旧值有没有变化,这里存在这样一个有意思的问题。比如一个旧值A变为了成B,然后再变成A,刚好在做CAS时检查发现旧值并没有变化依然为A,但是实际上的确发生了变化。解决方案可以沿袭数据库中常用的乐观锁方式,添加一个版本号可以解决。原来的变化路径A->B->A就变成了1A->2B->3C。
2,自旋时间过长
使用CAS时非阻塞同步,也就是说不会将线程挂起,会自旋(简单来说就是一直循环)进行下一次尝试,如果这里自旋时间过长对性能是很大的消耗。
3,只能保证一个共享变量的原子操作
当对一个共享变量执行操作时CAS能保证其原子性,如果对多个共享变量进行操作,CAS就不能保证其原子性。但可以通过新建一个类,其中的成员变量就是这几个共享变量,然后将这个对象做CAS操作就可以保证其原子性(atomic中提供了AtomicReference来保证引用对象之间的原子性)
3,乐观锁
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
4,悲观锁
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
网友评论