阅读 425

深入了解Synchronized原理

Synchronized

互斥性

同一个时间只允许一个线程拥有一个对象锁,这样在同一时间只有一个线程对需要同步的代码块进行访问

可见性

必须确保在某个线程的某个对象锁在释放之前,对某个共享变量所做的改变,对于下一个拥有在这个对象锁的线程是可见的,否则另外线程读取的是本地的副本从而进行操作,导致结果不一致。

重入性

从互斥锁的设计上来说,一个线程试图操作一个由其他线程持有的临界资源的时候,这个线程会处于堵塞状态。

如果一个线程再次请求自己持有对象锁的临界资源的时候,这就属于重入锁。

因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

获取对象锁的方式

获取对象锁的方式

  1. 修饰实例方法,作用于当前实例加锁,进行同步代码块之前需要获得当前实例的锁(Synchronized method)
  2. 修饰代码块,指定加锁对象,作用于给定对象加锁,进入同步代码快之前要获得给定对象的锁(Synchronized instance)
  3. 修饰静态方法,作用于当前类对象加锁,进入同步代码块之前要获得当前类对象的锁(Synchronized static method)
  4. 修饰类对象,作用于类对象加锁,进入同步代码块之前要获得指定类对象的锁(Synchronized **.class)

对象锁和类锁的区别

  1. 一个线程可以访问对象的同步代码块时,另外一个线程也可以访问同一个对象的非同步代码块
  2. 若锁住的是同一个对象,其他线程访问对象的同步代码块或者同步方法的时候会被阻塞
  3. 同一个类的不同对象的对象锁互不干扰
  4. 类锁是一种特殊的锁,因为类就是Class的实例,所以只要不同对象都是属于同一个类,那么他们的类锁都是一样的
  5. 类锁和对象锁互不干扰

底层原理

锁对象存储在Java对象头里面

位数 头对象结构
32 Mark word 存储对象的HashCode,GC分代年龄,锁类型,锁标记
32 Class MeteDataAddress 类型指针:指向实例对象所属的类

MarkWord被设定为一个非固定的数据结构,用来存储更多的数据,结构如下(这里不是很懂)

Monitor(内部锁,Monitor锁,管程,监视器锁,也就是和对象锁对应的对象)

每个对象都存在这一个Monitor与之关联

每个Java对象天生带有这把看不见的锁,在MarkWord的结构中,重量级锁的标记为是10,也就是指针就是指向Monitor对象的起始地址,在这里也就说明了Synchronized的默认锁是重量级锁。monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态

在Java虚拟机中,Monitor是有MonitorObject所实现的,部分结构如下

_owner:指向持有ObjectMonitor对象的线程

_WaitSet:存放处于wait状态的线程队列

_EntryList:存放处于等待锁block状态的线程队列

_count:用来记录该线程获取锁的次数

ObjectMonitor中有两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当有多个线程访问同一块同步代码块的时候,线程会线程会进入_EntryList,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程,同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSet集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。

Monitorenter和Monitorexit

Synchronized代码块执行原理

字节码中可知同步语句块的实现使用的是monitorentermonitorexit 指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置 。当执行monitorenter指令时,如果当前线程获取对象锁所对应的monitor的特权的时候

1 会去检查monitor的对象的count是否为0

2 如果为0的话就获取成功,并且将count置为1

3 倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。

编译器将会确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都有执行其对应 monitorexit 指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。一般字节码文件中都会多出一条monitorexit指令。

Synchronized方法执行原理

方法级的同步是隐式,即无需通过字节码指令来控制的,它实现在方法调用和返回操作之中。JVM可以从ACC_SYNCHRONIZED 访问标志区分一个方法是否同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先持有monitor,然后再执行方法,最后再方法完成(无论是正常完成还是非正常完成)时释放monitor。

如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的monitor将在异常抛到同步方法之外时自动释放

锁的类型

自旋锁

synchronized在jdk1.6之前的锁是重量级锁,对于互斥同步的性能来说,阻塞挂起的是影响最大的。因为挂起线程和恢复线程都是要让操作系统从用户态转化到内核态中完成,而这两个状态的转换是比较影响性能的。

大多数情况下,线程拥有锁的时间不会太长,如果直接挂起的话,会影响系统的性能。因为前面说过,线程切换是需要在操作系统的用户态和内核态之间转换的。所以为了解决这个问题,引进了自旋锁。

自旋锁假设在不久,当前线程可以获得这个锁,因此JVM就让这个想要获得锁的线程,先做几个空循环先,让这个线程先不要放弃占有CPU资源的机会,经过若干次空循环之后,如果获得锁,那么就顺利的进入临界区。否则,你也不能让这个线程一直占有CPU资源呀,所以经过大概10次空循环之后,就只能老老实实地挂起了。

自旋适应锁

自旋适应锁就是从自旋锁改进而来的。在自旋锁的基础上,假如A线程通过自旋一定的时间之后获得了锁,然后释放锁。这时B线程也获得了这个锁,如果此时A线程再次想得到这个锁,那么JVM就会根据之前A线程曾经获得过这个锁,那么我就给你适当地增加一点空循环的次数,比如说从10次空循环到100次。假如有个C线程,他也想获得这个锁,也得自旋等待,可是很少轮到他或者没得到过这个锁(可能是被A抢了机会或者其他的),那么JVM就会认为C线程以后可能没什么机会获得了,就适当地减少C线程的空循坏次数甚至不让他做空循环。

偏向锁

如果A线程第一次获得锁,那么锁就进入偏向模式(虚拟机把对象头中的标志位设为“01”),MarkWord的结构也变成偏向锁结构,如果没有其他线程和A线程竞争,A线程再次请求该锁时,无需任何同步操作

只需要检查MarkWord的锁标记位是否为偏向锁和当前线程的Id是否为ThreadId即可。

也就是说当一个线程访问同步块并且获取锁的时候,会通过CAS操作在对象头的偏向锁结构里记录线程的ID,如果记录成功,线程在进入和退出同步块时,不需要进行CAS操作来加锁和解锁,从而提高程序的性能。

TIPS:偏向锁只能被第一个获取它的线程进行 CAS 操作,一旦出现线程竞争锁对象,其它线程无论何时进行 CAS 操作都会失败。

加锁具体步骤如下

  1. 先检查Mark Word是否为可偏向状态,也就是说是否 是偏向锁1,锁标识位为01

  2. 如果是可偏向状态,那么就测试Mark Word结构的线程ID是不是和当前线程的ID一致,

    如果是就直接执行同步代码块。

    如果不是就通过CAS操作竞争锁,

    ​ 如果操作成功,就把Mark Word的线程ID设置为线程的ID

    ​ 如果操作失败,那么就说明此时有多线程竞争的状态,等到安全点,获得偏向锁的线程就挂起,进行解锁操作。偏向锁升级为轻量锁,被阻塞在安全点的线程继续往下执行同步代码块。

解锁

当获得偏向锁的线程挂起之后,就会进行解锁操作。

在解锁成功之后,JVM判断此时线程的状态,

如果还没有执行完同步代码,则直接将偏向锁升级为轻量级锁,然后继续执行剩下的代码块。

如果此时已经执行完同步代码,则撤销锁为无锁状态,以后执行同步代码的时候JVM则会直接升级为轻量锁。

轻量锁(加锁解锁操作是需要依赖多次CAS原子指令的)

偏向锁一旦受到多线程竞争,就会膨胀为轻量锁

获取锁

  1. 先判断当前对象是否处于无锁状态,如果是,JVM就首先在想要获取这个锁的线程的栈帧中建立一个锁记录(Lock Record)的空间,其中header部分用来存储Mark Word的备份,否则执行3。
  2. JVM利用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针,如果成功,那么就获得轻量锁,就将标志位设置为00,执行同步代码块,否则执行3。
  3. 判断当前对象的Mark Word是否指向当前想要竞争的线程的锁记录,如果是表示则该线程拥有这个轻量锁,继续执行同步代码块,也就是重入。否则,说明这个轻量锁已经被其他线程拥有,那么这个先进行自旋获取锁,如果一直没有得到锁,那么轻量锁则要膨胀为重量锁(也就是将标记为设置为10),锁标记设置为10,后面等待的线程则会进入阻塞状态,如果通过自旋成功获取了锁,那么轻量锁不会膨胀为重量锁。

释放锁

  1. 取出线程锁记录之前保存的轻量锁的Mark Word记录,通过CAS操作将取出的记录替换当前对象的Mark Word中
  2. 判断当前对象的Mark Word是否指向当前线程的锁记录
  3. 如果1,2都成功,那么就成功释放锁
  4. 如果1失败,那么就是之前有过线程对当前对象的锁竞争过,但是失败了,由轻量级锁变为重量级锁,导致Mark Word的结够发生了改变。那么后面就释放锁,唤醒等待的线程,进行新一轮的竞争。

重量级锁

重量级锁通过对象内部的监视器(monitor)实现

其中monitor的本质是依赖于底层操作系统的Mutex Lock实现

操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

锁的升级

锁主要存在四种状态,无状态锁,偏向锁,轻量锁,重量锁,会随着线程竞争的程度逐渐增大。锁只可以单向升级,不可以降级。

主要是为了提高获得锁和解锁的效率。

各个状态锁的优缺点对比

锁类型 特征 优点 缺点 使用场景
偏向锁 只需要比较ThreadId 加锁和解锁不需要额外的消耗,和执行非同步代码块时间相差无几 如果线程之间有竞争,会增加锁撤销的消耗 当程序大部分只有一个线程操作的时候
轻量锁 自旋 竞争线程不会阻塞,提高了程序的响应速度 始终得不到锁的线程使用自旋会消耗CPU 追求响应时间,同步执行代码比较快的时候
重量锁 依赖Mutex(操作系统的互斥) 线程竞争不使用自旋,不怎么会消耗CPU 线程阻塞,响应缓慢 同步代码执行比较慢的情况

最后

这里有一张原理图(盗用别人的图),把上述的文字都进行了一个总结

参考

juejin.im/entry/58998…

blog.csdn.net/championhen…

blog.csdn.net/javazejian/…

关注下面的标签,发现更多相似文章
评论