synchronized原理分析
概述
前面我们介绍了线程相关的知识,接下来我们看一下java给我们提供的用于解决线程并发带来问题的关键字synchronized,之所以将其单独列出 而不是和java中各种常见锁一起列出,是因为synchronized相对来说是比较独立的,其底层是jvm给我们提供的原语,而java中的锁则是 基于AQS框架来实现的,因此本小节针对synchronized单独做一个总结。
重要信息
锁的实现在操作系统层面上只有以下几种情况:
- 原子操作:只针对简单的数据结构有效
- 关中断:对于多核cpu失效
- 锁总线:锁的颗粒度比较大,对性能影响较大
- 自旋锁:依赖于操作系统特定的指令,将内存(a)和置位(b)的值通过原子指令实现交换,从而实现加锁的过程,当然如果加锁失败就继续通过自旋实现数据的交换
- 信号量:和自旋锁本质是同一种技术,只是加锁失败不再让其继续自旋,而是将其丢到一个队列里面,当持有锁的线程执行完成之后从队列中唤醒。JAVA的AQS就是这种机制
示例
在开始讲述synchronized之前,我们先用一个例子引出synchronized的使用,假定有一个共享变量,我们启动两个线程执行变量的++操作,我们知道 ++操作并不是一个原子操作,在线程进入临界区执行操作更新共享变量的时候,如果没有加锁的话应该是有问题的,对应的代码如下:
1 | public class SyncTest { |
多次测试,我们会发现执行的结果不定,当我们对run操作加上synchronized再看一下执行效果就会发现,最终值是一个确定的值(不过 这里的synchronized应该需要锁住一个共享变量),具体代码如下:
1 | class MyThread extends Thread { |
如果synchronized仅仅修饰的是run方法,那么得到的最终的结果仍然是不确定的,至于原因嘛:不同的线程进入临界区执行自增操作的时候 获取到的锁不是同一个锁,所以这种方式并不能够实现真正的互斥操作,这里便涉及到synchronized的使用方式了。
使用方式
synchronized有三种使用方法,每种使用方法都针对了不同的作用域,接下来我们来看一下具体的使用过程
普通同步方法
下面三个方法本质上是一样的,对于第一个方法,多个线程在访问的时候会去抢同一把锁,该锁由当前对象持有,也就是new出来的SyncUsed对象,因此 test与test1本质上是一样的,至于test2,则是将获取锁的对象一般化:既然多个线程争夺同一个SyncUsed对象持有的锁,为什么不能另一个对象的锁呢?
1 | package com.sync; |
这里可以多考虑一下,如果由A、B、C三个线程如果A线程调用了test方法,B线程在同一时刻调用了test1方法,C线程又在同一时刻 调用了test2方法,那么这三个线程会形成阻塞么?
答案是test和test1会相互阻塞,均不会和test2形成阻塞。至于原因,我们会在讲解synchronized原理的时候讲解。
静态同步方法
对于静态同步方法,下面三个方法本质上也是一样的。synchronized修饰的是静态的方法,既然静态的,那肯定是全局的了, 因此,这里的synchronized获取的是这个类的锁,所以这里的test与test1本质上也还是一样的, 而test2的话则是更一般化的静态同步方法(使用了一个静态对象:静态的必定是全局的)
1 | package com.sync; |
同步代码块
同步代码块应该是我们最常用的一种同步机制了,如下:
1 | void test2() { |
相信这一种同步的方式我们也不陌生了,这个在上面普通同步方法中我们也已经见过了,这几种方式的区别仅仅是synchronized修饰的 作用域的区别。
上面我们讲述了synchronized的几种使用方式,总结一下其特点就是:当一个线程试图访问被synchronized修饰的方法的时候,首先会获取 一个锁,当方法执行完成或者在运行的过程中抛出异常的时候会释放锁。 接下来我们来分析一下synchronized的原理。
原理解析
JAVA对象头长度一般是2个字(数组除外,数组3个字)包含的数据结构是:
- 空位、Hash code、分代回收年龄、锁标记(偏向1bit, 锁标记2bit)
- Class metadata:用来指向对象的类的指针,用来确定对象是哪个类的实例。
1、线程包含一个monitor record列表,每一个record都记录了线程请求锁的起止时间 2、对象都包含了一个monitor锁,这个monitor就是用上面说的对象头的信息来实现的,锁标记的状态可以分为:
- 00(轻量级锁)
- 1 - 01(偏向锁)
- 0 - 01(无锁)
- 10(重量级锁)
偏向锁的出现是为了解决无竞争情况下的同步原语。 monitor使用条件变量+等待队列实现线程间的协作,不过monitor依赖于操作系统的互斥锁,因此需要通过系统调用来操作,开销是比较大的。 monitor可以认为是对象的伴生对象,一旦对象被锁住了,对象头的lockword就会指向monitor的起始地址,同时在monitor中存放了线程的信息,其数据结构大致如下:

如上,在monitor对象中包含了nest字段用来保存monitor被重入的次数,因此采用monitor机制实现的锁是可以重入的。
monitor实现加锁的流程是: 1、线程尝试获取对象的monitor来完成对对象的加锁,如果发现对象的锁已经获取了,这个时候会进入entry-set中进行等待 2、等到其他的线程释放了monitor,会隐式的触发一个通知,告知等待的线程进行抢monitor,如果等待的线程拿到了就可以继续执行 3、不过如果该线程拿到了monitor之后,发现条件还不具备,这个时候就可以调用对象的wait方法释放monitor,同时将该线程丢到wait-set中,此时进入到步骤2
锁膨胀过程: synchronized的monitor最开始采用的是操作系统的互斥锁来实现,这样每次获取临界区的资源的时候都会进行系统调用来实现加锁的过程,这个过程的开销是比较大的,因此后来对synchronized的加锁机制进行了优化,这就有了后来的锁膨胀机制。 锁膨胀的过程是用到了对象头的markword,其膨胀的过程是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 无锁:默认情况下新建一个锁对象还没有线程使用过的时候,此时对象头的标记是001,表示对象是无锁状态
- 偏向锁:此时线程A进来,发现对象无锁就会把对象头的信息进行更改,设置为101,并将A线程ID写入对象头,表示对象偏向A线程
- 轻量级锁:在A持有锁的期间,线程B尝试竞争锁,此时就B就会进入自旋,通过自旋将对象头中的A线程ID置换为B,这个过程是自旋的过程,如果B在竞争A的偏向锁的时候,A已经不活动或者A已经退出同步代码块,这个时候在撤销A的偏向锁的时候就会生成一个B的偏向锁,如果CAS自旋超过一定次数还没有成功后,就会由偏向锁向轻量级锁进行转换
- 重量级锁:在通过自旋来竞争锁而获取不到的时候,此时就会进入到重量级锁,此时对象的mark word也会变成一个monitor对象,从而继续通过entry-set和wait-set来提供锁的机制,此时再获取锁就需要通过操作系统层面上的互斥锁来实现。
综上:偏向锁不是锁,而是对象头的一个标记;轻量级锁是自旋机制,也就是获取不到锁的时候通过自旋再次获取;重量级锁是操作系统层面上的互斥锁。
参考文章列表
https://blog.csdn.net/cold___play/article/details/104044508 https://kongzheng1993.github.io/2020/04/17/kongzheng1993-Java_Monitor/ https://oscimg.oschina.net/oscnet/2bcc8161c52eb100d2c7c4c96c70d3c5823.jpg http://3ms.huawei.com/km/blogs/details/12284271 https://cmsblogs.cn/?s=synchronized https://www.jianshu.com/p/05d36bcb08a1