赞
踩
Java中每一个对象都可以作为锁,具体的表现为以下3中方式
当一个线程试图访问同步代码块时,他必须先获得锁。退出或抛出异常必须释放锁。那么锁到底存在哪里呢?锁中存储的是什么信息呢?
从JVM规范中可以看到Synchronize在JVM中的实现原理,JVM是基于进入或退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。
1、代码块同步是使用monitorenter和monitorexit指令实现,
2、而方法同步是使用另一种方式实现的,细节在JVM规范中并未详细说明。但是方法同步同样是是用这两个指令实现。
monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入在结束处或者异常处。JVM规定每一个monitorexter都有一个monitorexit与之相对应。任何对象都有一个monitor对象与之关联,当monitor对象被持有时,它将处于锁定状态。线程执行到monitorenter时,将会尝试获取对象所对应的monitor的所有权,即尝试获取对象锁。
public class SynchronizeDemo {
public synchronized void test1(){}
public void test2(){
synchronized (this){
}
}
}
先用javac -g SynchronizeDemo.java文件成class文件
再用javap -v SynchronizeDemo.class文件查看指令信息
同步方法块:使用monitorenter和monitorexit
同步方法:使用ACC_SYNCHRONIZED来实现
现在回到前面的问题,锁到底存在哪里呢?答案就是存放在Java的对象头里,接下来让我们看下Java对象头的结构。
Synchronize用的锁是存放在Java对象头里的。如果对象是数组类型,那么虚拟机用3个字宽(word)来存储对象头,如果对象是非数组结构,则用2个字宽存储对象头,在32位虚拟机中1字宽等于4字节,即32位。
Hotspot虚拟机对象头中主要存放两部分数据,一个是Mark Word(标记字段),一个是Klass Pointer(类型指针),其中Klass pointer是对象指向它的类元数据指针,虚拟机通过这个指针用来确认这个对象属于哪个类的实例。Mark Word用来存储对象的HashCode和锁信息,他是实现偏向锁和轻量级锁的关键。
Java对象头的结构如下:
长度 | 内容 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储hashcode或者锁信息等 |
32/64bit | Class Meatadata Adress | |
即 Klass Pointer | 存储到对象类型数据的指针 | |
32/32bit | 数组长度 | 数组的长度 |
Mark Word结构如下:
锁状态 | 25bit | 4bit | 1bit是否偏向锁 | 2bit锁标志位 |
---|---|---|---|---|
无锁状态 | hashcode | GC分代年龄 | 0 | 01 |
在运行期间Mark Word里存储的数据会随着锁状态位的变化而变化,,Mark Word里可能存储以下4中数据:
锁状态 | 25bit | 4bit | 1bit | 2bit | |
---|---|---|---|---|---|
23bit | 2bit | 是否偏向锁 | 锁标志位 | ||
轻量级锁 | 指向栈中的锁记录的指针 | 00 | |||
重量级锁 | 指向互斥量的(重量级锁)指针 | 10 | |||
GC标记 | 空 | 11 | |||
偏向锁 | 23bit线程ID | Epoch | |||
3bit | 3bit | ||||
GC分代年龄 | 1 | 01 |
为了减少锁的性能消耗,从jdk1.6开始对锁的实现进行大量的优化,如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销。
锁主要存在4种状态依次为:无锁状态,偏向锁,轻量级锁,重量级锁。他们会随着竞争的升级,而逐渐升级,但是锁只能升级而不能降级。这种策略是为了提高获得锁和释放锁的效率。
线程的阻塞和唤醒需要CPU从用户态转换到内核态,对CPU来说频繁的阻塞和唤醒是比较消耗性能的。同时我们发现在许多应用上面,对象锁的锁状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。
何谓自旋锁?
所谓自旋锁,就是让该线程等待一段时间,不会被立即挂起,看持有锁的线程是否会很快释放锁。
JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。
它怎么做呢?
线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功的,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。有了自适应自旋锁,随着程序运行和性能监控信息的不断完善,虚拟机对程序锁的状况预测会越来越准确,虚拟机会变得越来越聪明。
为了保证数据的完整性,我们在进行操作时需要对这部分操作进行同步控制,但是在有些情况下,JVM检测到不可能存在共享数据竞争,这是JVM会对这些同步锁进行锁消除。锁消除的依据是逃逸分析的数据支持。
我们在使用一些JDK的内置API时,如StringBuffer、Vector、HashTable等,这个时候会存在隐形的加锁操作。
我们知道在使用同步锁的时候,需要让同步块的作用范围尽可能小,仅在共享数据的实际作用域中才进行同步。这样做的目的是为了使需要同步的操作数量尽可能缩小,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。 在大多数的情况下,上述观点是正确的。但是如果一系列的连续加锁解锁操作,可能会导致不必要的性能损耗,所以引入锁粗化的概念。
那什么是锁粗化?
就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
如上面实例:vector每次add的时候都需要加锁操作,JVM检测到对同一个对象(vector)连续加锁、解锁操作,会合并一个更大范围的加锁、解锁操作,即加锁解锁操作会移到for循环之外。
HotSpot作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
偏向锁的逻辑如下:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程进入和退出同步块时就不需要进行CAS操作来加锁和解锁。只需要简单测试下对象头中的Mark Word中是否存储了当前线程的ID,如果测试成功表示已经获取锁成功。如果失败,则需要在测试下Mark Word中的是否偏向锁是否为1,如果没有设置,则尝试CAS竞争锁。如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
流程如下:
偏向锁的释放采用了一种只有竞争才会释放锁的机制,线程是不会主动去释放偏向锁,需要等待其他线程来竞争。偏向锁的撤销需要等待全局安全点(这个时间点是没有正在执行的代码)。
可以使用JVM参数: -XX:BiasedLockingStartupDelay=0 来关闭偏向锁激活延迟
使用:-XX:-UseBiasedLocking=false来关闭偏向锁
引入轻量级锁的主要目的是在只有少量线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。
当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:
释放锁轻量级锁的释放也是通过CAS操作来进行的,主要步骤如下:
锁 | 优点 | 缺点 | 使用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,与执行非同步方法仅存在纳秒级的差距 | 如果线程间存在竞争,会带来额外的锁撤销的消耗 | 适用于只有一个线程访问同步块的情况 |
轻量级锁 | 竞争的线程不会堵塞,提高了程序的响应速度 | 始终得不到锁的线程,使用自旋会消耗CPU | 追求响应时间,同步块执行速度非常块 |
重量级锁 | 线程竞争不使用自旋,不会消耗CPU | 线程堵塞,响应时间缓慢 | 追求吞吐量,同步块执行速度比较慢 |
从图中可以看到。任意线程对Object的访问,首先要获取Monitor监视器,如果获取失败,线程进入同步队列中,线程状态变为BLOCKED。当获取锁的线程释放了锁,同时在释放锁之后唤醒阻塞在同步队列中的线程使其尝试对监视器的重新获取。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。