synchronized原理与使用
synchronized是Java中用于解决并发情况下数据同步访问的一个很重要的关键字.当我们想要保证一个共享资源在同一时间只会被一个线程访问到时,我们可以在代码中使用synchronized关键字对类或者对象加锁.那么本文来介绍一下synchronized关键字的实现原理是什么.在阅读本文之间,建议先看下Java虚拟机是如何执行线程同步的
synchronized是如何实现
理论层面上保证线程安全的原理
Java每一个对象都可以作为锁.具体表现:
- 对于普通方法,锁是当前实例对象,this对象
- 对于静态同步方法,锁是当前类的Class对象
- 对于同步方法块,锁是Synchronized括号里配置的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27private static int value;
/**
* synchronized放在普通方法上,内置锁就是当前类的实例
* @return
*/
public synchronized int getNext() {
return value ++;
}
/**
* 修饰静态方法:内置锁是当前Class字节码对象Sequence.class
* @return
*/
public static synchronized int getPrevious() {
return value --;
}
public int xx() {
synchronized (Sequence.class) {
if (value > 0) {
return 1;
} else {
return -1;
}
}
}JVM层面上保证线程安全原理
反编译
众所周知在Java中,synchronized有两种使用形式(同步方法和同步代码块).代码如下:我们先来使用Javap来反编译以上代码,结果如下(部分无用信息过滤掉了):1
2
3
4
5
6
7
8
9
10
11
12public class SynchronizedTest {
public synchronized void doSth(){
System.out.println("Hello World");
}
public void doSth1(){
synchronized (SynchronizedTest.class){
System.out.println("Hello World");
}
}
}反编译后,我们可以看到Java编译器为我们生成的字节码.在对于doSth和doSth1的处理上稍有不同.也就是说JVM对于同步方法和同步代码块的处理方式不同1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31public synchronized void doSth();
descriptor: ()V
flags: ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello World
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
public void doSth1();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: ldc #5 // class com/hollis/SynchronizedTest
2: dup
3: astore_1
4: monitorenter
5: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
8: ldc #3 // String Hello World
10: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit
15: goto 23
18: astore_2
19: aload_1
20: monitorexit
21: aload_2
22: athrow
23: return
对于同步方法,JVM采用ACC_SYNCHRONIZED标记符来实现同步;对于同步代码块,JVM采用monitorenter、monitorexit两个指令来实现同步
关于这部分内容,在JVM规范中也可以找到相关的描述
同步方法
The Java® Virtual Machine Specification中有关于方法级同步的介绍:
主要说的是:方法级的同步是隐式的.同步方法的常量池中会有一个ACC_SYNCHRONIZED标志.当某个线程要访问某个方法的时候,会检查是否有ACC_SYNCHRONIZED,如果有设置,则需要先获得监视器锁,然后开始执行方法,方法执行之后再释放监视器锁.这时如果其他线程来请求执行方法,会因为无法获得监视器锁而被阻断住.值得注意的是,如果在方法执行过程中,发生了异常,并且方法内部并没有处理该异常,那么在异常被抛到方法外面之前监视器锁会被自动释放
同步代码块
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对.任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态.线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁
同步代码块使用monitorenter和monitorexit两个指令实现.The Java® Virtual Machine Specification中有关于这两个指令的介绍:
大致内容如下:可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁.每个对象维护着一个记录着被锁次数的计数器.未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为1,当同一个线程再次获得该对象的锁的时候,计数器再次自增.当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减.当计数器为0的时候,锁将被释放,其他线程便可以获得锁
Java对象头
synchronized用的锁是存在Java对象头里的.如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头.在32位虚拟机中,1字宽等于4字节,即32bit,如下表所示
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的hashcode或锁信息等 |
32/64bit | Class Metadata Address | 存储到对象类型数据的指针 |
32/64bit | Array length | 数组的长度(如果当前对象是数组) |
Java对象头里的Mark Word里默认存储对象的HashCode、分代年龄和锁标记位.32位JVM
的Mark Word的默认存储结构如下表所示
锁状态 | 25bit | 4bit | 1bit是否是偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | 对象的hashcode | 对象分代年龄 | 0 | 01 |
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化.Mark Word可能变化为存储以下4种数据,如下表所示
在64位虚拟机下,Mark Word是64bit大小的,其存储结构如下表所示:
总结
同步方法通过ACC_SYNCHRONIZED关键字隐式的对方法进行加锁.当线程要执行的方法被标注上ACC_SYNCHRONIZED时,需要先获得锁才能执行该方法
同步代码块通过monitorenter和monitorexit执行来进行加锁.当线程执行到monitorenter的时候要先获得所锁,才能执行后面的方法.当线程执行到monitorexit的时候则要释放锁
每个对象自身维护这一个被加锁次数的计数器,当计数器数字为0时表示可以被任意线程获得锁.当计数器不为0时,只有获得锁的线程才能再次获得锁.即可重入锁
无论是ACC_SYNCHRONIZED还是monitorenter、monitorexit都是基于Monitor实现的,在Java虚拟机(HotSpot)中,Monitor是基于C++实现的,由ObjectMonitor实现
ObjectMonitor类中提供了几个方法,如enter、exit、wait、notify、notifyAll等.sychronized加锁的时候,会调用objectMonitor的enter方法,解锁的时候会调用exit方法(关于Monitor详见深入理解多线程(四)—— Moniter的实现原理)
synchronized和原子性、可见性、有序性之间的关系
synchronized与原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行
线程是CPU调度的基本单位.CPU有时间片的概念,会根据不同的调度算法进行线程调度.当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权.所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit.前面中介绍过,这两个字节码指令,在Java中对应的关键字就是synchronized
通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到.因此在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁.即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是他并没有进行解锁.而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码.直到所有代码执行完.这就保证了原子性
synchronized与可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存.不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行.所以就可能出现线程1改了某个变量的值,但是线程2不可见的情况
前面我们介绍过,被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁.而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中.这样解锁后,后续线程就可以访问到被修改后的值
所以synchronized关键字锁住的对象,其值是具有可见性的
synchronized与有序性
有序性即程序执行的顺序按照代码的先后顺序执行
在Java内存模型中分析过:除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save有可能被优化成load->save->add.这就是可能存在有序性问题
这里需要注意的是:synchronized是无法禁止指令重排和处理器优化的.也就是说,synchronized无法避免上述提到的问题
为什么还说synchronized也提供了有序性保证呢?
这就要再把有序性的概念扩展一下了.Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的.如果在一个线程中观察另一个线程,所有操作都是无序的
这其实和as-if-serial语义有关;as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变.编译器和处理器无论如何优化,都必须遵守as-if-serial语义
简单说就是:as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的.当然实际上还是有重排的,只不过我们无须关心这种重排的干扰
所以呢由于synchronized修饰的代码,同一时间只能被同一线程访问.那么也就是单线程执行的.所以可以保证其有序性
synchronized与锁优化
synchronized其实是借助Monitor实现的,在加锁时会调用objectMonitor的enter方法,解锁的时候会调用exit方法.事实上只有在JDK1.6之前,synchronized的实现才会直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁
在JDK1.6中出现对锁进行了很多的优化,进而出现轻量级锁、偏向锁、锁消除、适应性自旋锁、锁粗化(自旋锁在1.4就有,只不过默认的是关闭的,jdk1.6是默认开启的),这些操作都是为了在线程之间更高效的共享数据,解决竞争问题
锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级.锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁.这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率,下文会详细分析
关于自旋锁、锁粗化和锁消除可以参考深入理解多线程(五)—— Java虚拟机的锁优化技术
偏向锁
很多情况下,竞争锁不是由多个线程,而是由一个线程在使用.相当于单线程在访问,每次像多线程获取资源会浪费资源,于是就有了偏向锁
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID.
- 该线程再次在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁.如果测试成功,表示线程已经获得了锁.
- 如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁);如果没有设置,则使用CAS竞争锁;如果设置了.则尝试使用CAS将对象头的偏向锁指向当前线程
偏向锁的使用场景是只有一个线程访问同步代码块的场景
偏向锁的撤销
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他锁尝试竞争偏向锁的时候持有偏向锁的线程才会去释放偏向锁
偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码).它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程
偏向锁的关闭
偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟:-XX:BiasedLockingStartupDelay=0.如果你确定应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking=false,那么程序默认会进入轻量级锁状态
轻量级锁
可以同时让多个线程进入同步代码块中,同时都能获取锁
轻量级锁加锁
轻量级锁是如何加锁的?在线程执行同步代码块前,java虚拟机会先在当前线程的栈帧中创建用于锁记录的空间,并将对象头中的MarkWord复制到锁记录空间,官方称为Displaced Mark Word.然后线程尝试使用
CAS将对象头中的Mark Word替换为指向锁记录的指针,当竞争成功后,MarkWord就会把锁标志位改成轻量级锁,当前线程获得锁,接着执行同步体;当竞争失
败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁
比如多个线程一块访问,一个获取了轻量级锁,另外一个需要等待执行,它也要复制MarkWord到虚拟机栈中,接着她要修改MarkWord,发现被别的线程获得了锁,它修改不成功,就不停的去修改,不停的失败,直到前面的线程将锁给释放了,于是它可以获得了,上述的过程就是自旋锁
当第一个线程执行完毕后,第二个线程获取到之后就把锁升级成重量级锁,线程就会阻塞,当第一个线程执行完毕释放锁并唤醒第二个线程,接着第二个线程开始继续执行,这就是轻量级锁的执行过程
轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生.如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁.下图是两个线程同时争夺锁,导致锁膨胀的流程图
因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态.当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争
锁的优缺点对比
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差异 | 如果线程间存在锁竞争会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的场景 |
轻量级锁 | 竞争的线程不会存在阻塞,提高了线程的响应速度 | 如果始终得不到锁竞争的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程阻塞,响应时间缓慢 | 追求吞吐量,同步块执行速度 |
## synchronized和lock之间的关系 |