举例引出问题
public class test {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable runnable = new Runnable() {
@Override
public void run() {
add();
}
};
Thread thread = new Thread(runnable);
Thread thread1 = new Thread(runnable);
thread.start();
thread1.start();
thread.join();
thread1.join();
System.out.println("count:" + count);
}
public static void add() {
for (int i = 0; i < 5000; i++) {
count++;
}
}
}
上述代码中,启动两个线程,对变量count进行数值操作,两个线程分别执行5000次,按照预期结果来看,count的最终结果应该是10000,但是实际得到的数值却不是10000,并且实际得到的数值不是确定的。
那么为什么会出现这种情况呢?
因为JAVA中的++操作,它在语言层面虽然只是一行代码,看似是原子操作的,但是站在字节码的角度,它却不是原子操作的,所以两个线程在对count进行数值操作时,会出现线程之间的交错运行,导致count达不到预期的结果。
为什么线程之间交错运行就会导致count达不到预期结果呢?
因为两个线程进行计算的变量是属于共享变量,而不是线程私有的变量,所以两个线程进行计算时会出现一个线程将count复制到自己的空间进行计算了但是还没赋值给count,此时另一个线程读取count进行计算赋值,这种情况会导致count最终结果达不到预期。
如何解决这种情况?
由于count++操作在字节码的角度不是原子操作的,我们可以站在宏观的角度去解决,JAVA提供了synchronized关键字来帮助我们保证一组代码在执行时不会有别的线程来执行,这样子就可以保证在进行count++操作时只有一个线程在执行。
synchronized的使用可以作用于代码块或者是方法上
public class test {
public void function(){
synchronized (锁对象){
......
}
}
public synchronized void function1(){
......
}
}
在使用synchronized时要分清楚锁对象是谁,线程间所用的锁对象是否是同一个,如果线程间使用的锁对象不是同一个,则synchronized其实是没有意义的。
synchronized (this){
// 当前对象
}
Object lock = new Object();
synchronized (lock){
// lock对象
}
synchronized (XXX.class){
// XXX类
}
public synchronized void function(){
// 当前对象
}
public static synchronized void function(){
// 当前类
}
总结:
知道了synchronized的基本用法,synchronized需要与一个类或者对象进行关联,可以把类或者对象理解为一把锁,那么底层是怎么实现的呢?
在JVM规范中提到了synchronized在JVM中的实现原理,JVM是基于进入和退出Monitor对象来实现方法同步和代码块同步,但是两者的实现不一样。代码块同步是使用monitorenter和monitorexit指令实现的,而方法同步是使用另外一种方式实现的,方法同步的细节,JVM规范中没有详细说明,但是方法同步也是使用这两个指令进行实现的。
首先编译这段代码查看字节码
可以看到代码块同步中,monitorenter是在代码同步块开始的地方,monitorexit是在代码块方法结束的地方和异常处。
接下来查看同步方法的字节码
可以看到代码块同步可以看到monitorenter与monitorexit指令,但是方法块同步在编译后却没有看到。相关书籍中说明同步方法是使用另外一种方式进行实现的,但是本质都是使用monitorenter与monitorexit指令来进行实现。
synchronized用的锁是存在于java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型的,则用2个字宽来存储对象头。在32位虚拟机中,一个字宽等于4个字节,即32bit。
java对象头中的Mark Word里默认存储对象的HashCode、分代年龄、锁标记。32位的JVM的Mark Word默认存储如图所示。
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。
上图中可以看到轻量级,重量级,偏向锁,GC与正常状态下的Mark Word的存储内容
轻量级锁的使用场景:如果一个对象虽然有多线程要加锁,但加锁的时间是错开的(也就是没有竞争),那么可以
使用轻量级锁。
线程执行同步代码块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的Mark Word信息复制到锁记录中,官方称之为 Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,则当前线程获取到锁,如果失败则表示则表示有其他线程再竞争锁,当前线程尝试使用自旋来获取锁,当然 自旋已经是属于重量级锁的优化了。
上图是获取到轻量级锁的示意图,当前线程存储了对象头的Mark Word的信息,且CAS替换了对象头中的Mark Word,替换为指向锁记录的指针。
可重入锁的示意图,常见的情况就是一个线程执行A方法获取XXX锁成功,然后A方法中调用B方法,B方法中也需要获取XXX锁,由于当前线程已经在执行A方法时获取到XXX锁了,此时调用B方法获取XXX锁时也会在栈帧中创建一条锁记录。
轻量级锁解锁时,会使用CAS操作将Displaced Mark Word替换回对象头中,如果成功,则表示没有发生竞争,如果失败了,则表示当前锁存在竞争,锁就会膨胀为重量级锁,进行重量级锁的解锁操作。
上图中,Thread-0已经获取到了轻量级锁,此时Thread-1尝试获取锁对象,进行了Displaced Mark Word操作后进行CAS尝试替换对象头的Mark Word来指向Thread-1时会失败,因为此时的对象头的Mark Word信息指向了Thread-0,此时Thread-1会尝试 自旋 重试来获取锁,一定次数后如果获取不到锁,则会进行锁膨胀,将对象头(Object)申请Monitor锁,使对象头的Mark Word指向Monitor对象的地址,Monitor的Owner指向Thread-0,同时Thread-1进行Monitor的EntryList进行睡眠。
当Thread-0解锁后,会发现此时的锁已经不是轻量级锁,而升级位重量级锁,Thread-0会重置Monitor的Owner并唤醒EntryList中的线程来竞争锁。
自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
在 Java 6 之后自旋锁是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会
高,就多自旋几次;反之,就少自旋甚至不自旋。
Java 7 之后不能控制是否开启自旋功能
因为自旋是会消耗CPU资源的,为了避免无用的自旋,(比如获得锁的线程被阻塞住了),一旦锁升级为重量级锁,就不会再恢复到轻量级锁的状态,当锁处于这个状态下,其他线程尝试获取锁时,都会被阻塞住进入EntryList中,当持有锁的线程释放锁时会唤醒这些线程,被唤醒的线程开始锁的竞争。
HotSopt的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获取锁的代价更低而引入了偏向锁。当一个线程访问同步代码块并获取锁时,会在对象头和栈帧中的锁记录里存放锁偏向的线程ID,以后该线程在进入和退出同步代码块时不需要使用CAS操作来进行加锁和解锁,只需要简单的测试对象头的Mark Word中是否存储着指向当前线程的偏向锁,如果测试成功,则表示当前线程已经获取到锁了,如果失败了,则需要再次测试Mark Word中的偏向锁的标识是否设置为了1(表示当前是偏向锁),如果没有设置,则使用CAS来竞争(此时就是轻量级锁或者重量级锁了),如果设置了,则尝试将对象头的偏向锁指向当前线程。
关于轻量级锁,重量级锁,偏向锁,GC和正常状态下的Mark Word的格式,上文已经描述。
接下来使用jol来查看对象的Mark Word信息
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
Object lock = new Object();
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
上述代码中,默认情况lock对象应该是偏向锁的状态,进入同步代码块中也应该是偏向锁的状态
可以看到一开始对象的状态为001,是无锁的状态,在进入同步代码块中,变成了000,是轻量级锁的状态。
这是因为偏向锁是默认是延迟的,不会在程序启动时立即生效,如果想避免延迟,可以加 VM 参数 来禁用延迟
-XX:BiasedLockingStartupDelay=0
所以上述代码中,程序刚刚启动,偏向锁还没有启动,就进行了加锁操作,直接变为了轻量级锁
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
log.info(ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
log.info(ClassLayout.parseInstance(lock).toPrintable());
}
}
}
此时我们在方法开始时睡眠5s,再去创建锁对象,来查看锁对象的Mark Word信息
可以看到对象头的Mark Word信息是偏向锁的状态了。
可以添加 VM 参数禁用偏向锁
-XX:-UseBiasedLocking
如果禁用了偏向锁,那么一开始就直接是轻量级锁
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
log.info("初始化状态:{}",ClassLayout.parseInstance(lock).toPrintable());
new Thread(()->{
synchronized (lock){
log.info("第一次打印:{}",ClassLayout.parseInstance(lock).toPrintable());
}
},"t1").start();
TimeUnit.SECONDS.sleep(1);
new Thread(()->{
synchronized (lock){
log.info("第二次打印:{}",ClassLayout.parseInstance(lock).toPrintable());
}
},"t2").start();
}
}
可以看到一开始就是无锁状态,在获取锁时是轻量级锁
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
log.info("初始化状态:{}",ClassLayout.parseInstance(lock).toPrintable());
new Thread(()->{
synchronized (lock){
log.info("第一次打印:{}",ClassLayout.parseInstance(lock).toPrintable());
}
},"t1").start();
new Thread(()->{
synchronized (lock){
log.info("第二次打印:{}",ClassLayout.parseInstance(lock).toPrintable());
}
},"t2").start();
}
}
可以看到 我将main线程的休眠代码去掉,两个线程就变成了竞争的状态,锁就直接变成了重量级锁
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。他会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否还活着,如果线程不处于活动状态,则将对象头设置为无锁状态;如果线程还活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
log.info("初始化状态:{}",ClassLayout.parseInstance(lock).toPrintable());
lock.hashCode();
log.info("调用hashcode后:{}",ClassLayout.parseInstance(lock).toPrintable());
}
}
上述例子可以得出结论,在调用锁对象的hashcode后,会禁用掉偏向锁
为什么?
处于偏向锁状态的锁对象的Mark Word中保存的信息大部分都是ThreadID,已经没有足够的空间去保存hashCode了,那为什么调用hashCode后可以使用轻量级和重量级锁呢?
因为轻量级和重量级锁中,锁对象的信息会存放于线程的栈帧中(轻量级)或者Monitor中(重量级)。
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Object lock = new Object();
new Thread(()->{
log.info("t1初始化:{}",ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
log.info("t1获取锁:{}",ClassLayout.parseInstance(lock).toPrintable());
}
log.info("t1执行完同步代码块:{}",ClassLayout.parseInstance(lock).toPrintable());
synchronized (test.class){
test.class.notify();
}
},"t1").start();
new Thread(()->{
synchronized (test.class){
try {
test.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("t2初始化:{}",ClassLayout.parseInstance(lock).toPrintable());
synchronized (lock){
log.info("t2获取锁:{}",ClassLayout.parseInstance(lock).toPrintable());
}
log.info("t2执行完同步代码块:{}",ClassLayout.parseInstance(lock).toPrintable());
},"t2").start();
}
}
可以看到t2获取锁,锁对象的Mark Word信息变成了轻量级锁
@Slf4j
public class test {
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Vector<Dog> list = new Vector<>();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 30; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
synchronized (list) {
list.notify();
}
}, "t1");
t1.start();
Thread t2 = new Thread(() -> {
synchronized (list) {
try {
list.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
log.info("===============> ");
for (int i = 0; i < 30; i++) {
Dog d = list.get(i);
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t2");
t2.start();
}
}
class Dog{
}
当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至
加锁线程。
@Slf4j
public class test {
static Thread t1, t2, t3;
public static void main(String[] args) throws InterruptedException {
TimeUnit.SECONDS.sleep(5);
Vector<Dog> list = new Vector<>();
int loopNumber = 39;
t1 = new Thread(() -> {
for (int i = 0; i < loopNumber; i++) {
Dog d = new Dog();
list.add(d);
synchronized (d) {
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}
LockSupport.unpark(t2);
}, "t1");
t1.start();
t2 = new Thread(() -> {
LockSupport.park();
log.info("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
LockSupport.unpark(t3);
}, "t2");
t2.start();
t3 = new Thread(() -> {
LockSupport.park();
log.info("===============> ");
for (int i = 0; i < loopNumber; i++) {
Dog d = list.get(i);
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
synchronized (d) {
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
log.info(i + "\t" + ClassLayout.parseInstance(d).toPrintable());
}
}, "t3");
t3.start();
t3.join();
log.info(ClassLayout.parseInstance(new Dog()).toPrintable());
}
}
class Dog {
}
当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象
都会变为不可偏向的,新建的对象也是不可偏向的
锁 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
偏向锁 | 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差距 | 如果线程间存在竞争,会带来锁的撤销的消耗 | 适用于只有一个线程访问的场景 |
轻量级锁 | 竞争的线程不会阻塞,提高了相应速度 | 如果得不到锁竞争的线程会有自旋的额外CPU开销 | 追求响应时间,同步块执行速度非常快 |
重量级锁 | 线程竞争不属于自旋,不会消耗CPU | 线程会阻塞,响应时间慢 | 追求吞吐量 |