synchronized的原理

Synchronized底层原理

那Synchronized到底是如何锁住一个对象的呢?在理解之前,我们需要了解java对象头、Monior的概念。

这不得不提到java对象在JVM中存储的布局:对象头、实例变量、填充数据(这个不做了解)。这里我们需要了解的是对象头。
在这里插入图片描述

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。
使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了两部分信息,对象头以及实例数据(填充数据只是为了对齐字节码)。

简单的说一下实例变量、填充数据。

实例变量:
存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

填充数据:
由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

对象头

对象头中包括两部分:

  1. Mark Word 存储对象自身的一些运行时数据,其中就包括和多线程相关的锁的信息,它是实现轻量级锁和偏向锁的关键。。
  2. Klass Point 元数据其实维护的是指针,指向的是对象所属的类的instanceKlass,虚拟机通过这个指针来确定这个对象是哪个类的实例。

Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

Mark Word
Mark Word 用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。

Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
在这里插入图片描述

对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化。

Monior

监视锁Monior保证了在同一时刻只有一个线程能访问共享资源。

每一个Object对象都与一个Monitor对象对应,对象头的MarkWord中的LockWord指向monitor的起始地址。

Monitor相当于一个许可证,线程拿到许可证即可以进行操作,没有拿到则需要阻塞等待。

ObjectMonitor中有几个关键属性:

  1. _owner:指向持有ObjectMonitor对象的线程
  2. _WaitSet:存放处于wait状态的线程队列
  3. _EntryList:存放处于等待锁block状态的线程队列
  4. _recursions:锁的重入次数
  5. _count:用来记录该线程获取锁的次数

下面是一些常见的情况:

线程T等待对象锁:_EntryList中加入T

线程T获取对象锁:_EntryList移除T,_owner置为T,计数器_count+1

持有对象锁的线程调用wait():_owner置为T,计数器_count+1,_WaitSet中加入T

具体获取锁的过程:

JVM 是通过进入、退出对象监视器( Monitor )来实现对方法、同步块的同步的。

具体实现是在编译之后在同步方法调用前加入一个 monitor.enter 指令,在退出方法和异常处插入 monitor.exit 的指令。

其本质就是对一个对象监视器( Monitor )进行获取,而这个获取过程具有排他性从而达到了同一时刻只能一个线程访问的目的。

而对于没有获取到锁的线程将会阻塞到方法入口处,直到获取锁的线程 monitor.exit 之后才能尝试继续获取锁。
在这里插入图片描述

Java虚拟机对synchronize的优化

JVM对于synchronized的优化分为以下几种:

  1. 锁消除
  2. 锁粗化
  3. 使用偏向锁和轻量级锁

1.锁消除

原理:JVM在JIT(即时编译)的时候,扫描上下文,去除掉那些不可能发生共享资源竞争的锁,从而而节省了线程请求这些锁的时间。
例子:StringBuffer的append方法是一个同步方法,如果StringBuffer类型的变量是一个局部变量,则该变量就不会被其它线程所使用,即对局部变量的操作是不会发生线程不安全的问题。
在这种情景下,JVM会在JIT编译时自动将append方法上的锁去掉。

    // 在运行这段代码时,JVM可以明显检测到变量vector没有逃逸出方法vectorTest()之外,
    //所以JVM可以大胆地将vector内部的加锁操作消除。
    public void vectorTest() {
        Vector<String> vector = new Vector<String>();
        for (int i = 0; i < 10; i++) {
            vector.add(i + "");// vector是线程安全的,每个方法都有synchronized修饰
        }
        System.out.println(vector);
    }

2.锁粗化

原理:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
例子:在for循环里的加锁/解锁操作,一般需要放到for循环外。因为每次循环都要加锁不如一次性加完锁

注意:虽然JVM会自动进行优化,但不如我们写代码时将其规范写好!

    for(int i=0;i<100000;i++){  
        synchronized(this){  
            do(); 
        } 
    }
    
    // 被优化之后   
    synchronized(this){  
        for(int i=0;i<100000;i++){  
            do();  
        }
    }

3.使用偏向锁和轻量级锁

  1. java6为了减少经常获取和释放锁带来的开销,引入了轻量级锁和偏向锁。
  2. 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁,通过Mark Word中的锁标志来区分,偏向锁是01,轻量级锁是00,重量级锁是11。还有一个偏向锁标志位用一个字段标记,如果锁标志为01,偏向锁标志为0表示无锁,1表示有偏向锁。
  3. 随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁。但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

1.偏向锁
场景:大多数情况下,锁不仅不会被多线程竞争,反而还会只被一个线程多次的获取,这个时候就存在偏向锁。
原理:当一个线程拿到当前锁后,锁会在对象头和栈帧中记录这个线程的ID,等到下次这个线程来获取锁时,不用进行CAS的加锁和解锁,只需查看Mark Word中是否存储指向当前线程的偏向锁。

获取偏向锁的过程:
访问对象的Mark Word中锁的标志位,发现是01,为偏向锁,有两种情况:

  1. 有无锁的标志位为0,说明为无锁状态,线程通过CAS操作尝试获取偏向锁,如果获取锁成功,则将Mark Word的偏向线程ID设置成当前线程ID,并且标志位置1。
  2. 有无锁的标志位为1,见如下过程。

如果偏向锁的标志位为1:

  1. 访问对象的Mark Word中偏向锁的标志位是否为1,如果是1则为偏向锁。
  2. 判断当前线程ID是否和Mark Word中的偏向线程ID一致,如果相同则无需进行CAS操作得到锁。
  3. 如果不一致则通过CAS操作获取锁,成功后修改MarkWord中的偏向线程ID指向这个线程ID。
  4. 如果CAS获取偏向锁失败,则表示存在竞争,获取偏向锁的线程还未结束运行。获得偏向锁的线程会被挂起,达到安全局安全点时(在这个时间点没有正在执行的字节码),它会将偏向锁膨胀升级成轻量锁,然后继续从被阻塞在安全点处往下执行同步代码。
  5. 获取锁的线程运行结束,但不会主动释放锁,而是等待其他线程尝试获取时才会释放。
  6. 如果有其他线程尝试获取,此线程已结束,便将有无锁的标志置为0,释放获取的锁。

额外说明:

  1. 偏向锁默认在应用程序启动几秒钟之后才激活。
  2. 可以通过设置 -XX:BiasedLockingStartupDelay=0 来关闭延迟。
  3. 可以通过设置 -XX:-UseBiasedLocking=false 来关闭偏向锁,程序默认会进入轻量级锁状态。(如果应用程序里的锁大多情况下处于竞争状态,则应该将偏向锁关闭)

优点:加锁和解锁不需要额外的消耗,和执行非同步代码块的效率仅存在纳秒级的差距对于没有锁竞争的场合,偏向锁有很好的优化效果。

缺点:如果存在线程竞争,锁撤销会带来多于的开销,对于锁竞争比较激烈的场合,偏向锁就失效了,因此这种场合下不应该使用偏向锁,需要先升级为轻量级锁。

2.轻量级锁
访问对象的Mark Word中锁的标志位,发现是00,为轻量级锁,过程如下:

  1. 当使用轻量级锁时(锁标识位为00),线程在执行同步代码之前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中Mark Word复制到锁记录中(这个锁记录在栈帧中名为 Displaced Mark Word)
  2. 将对象头的Mark Word复制到栈帧中的锁记录后,虚拟机将尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果此时没有线程占用锁或者没有线程竞争锁,则当前线程获取到该锁,然后执行同步代码块。
  3. 如果在获取锁并且执行同步代码块的过程中,另外一个线程也完成了栈帧中锁记录的创建,并且已经将Mark Word复制到自己栈的锁记录中,然后尝试用CAS将对象头中的Mark Word修改为自己的锁记录指针,当时由于之前获取了轻量锁的线程已经修改过Mark Word了,所以此时对象头中的Mark Word与当前线程锁记录中的Mark Word值不相同,导致CAS失败。
  4. 然后该线程就会不断的执行CAS操作去替换Mark Word中的值,当循环次数或者循环时间达到了上限则停止。如果在结束之前CAS操作成功,则该线程获取该锁并且成功的修改了Mark Word中的值。
  5. 如果CAS后,仍然修改失败,则对象头中的Mark Word的值会被修改成指向重量锁的指针,然后该线程挂起进入阻塞态。
  6. 当持有锁的那个进程执行完代码块后,使用CAS操作将对象头Mark Word还原为最初的状态时(将对象头中指向锁记录的指针替换为Displaced Mark Word)。若发现Mark Word已经被修改为指向重量锁的指针,因此CAS操作失败,该线程唤起挂起中的线程进行新一轮竞争,而此时,所有竞争失败的锁都会被阻塞,而不是自旋。

在这里插入图片描述
优点:在没有多线程的竞争下,可以减少传统重量锁带来的消耗

缺点:竞争失败的锁会进行自旋,消耗CPU

应用:追求响应时间,同步代码块执行速度要非常快。

3.重量级锁

重量级锁就是上面说的通过占用monitor获取锁。

  1. java6之前的synchronized锁都是重量级锁,效率很低,因为monitor是依赖操作系统的Mutex Lock(互斥量)来实现的。
  2. 多线程竞争锁时,会引起线程的上下文切换(在时间片还没有用完的情况下进行了上下文切换),需要从用户态转化到核心态,这个状态切换需要很长的时间,所以时间成本很高。
  3. 在互斥的状态下,没有得到锁的线程将会被挂起,而挂起和恢复线程的操作都需要从用户态转化成内核态中完成。

优点:没有得到锁的线程不用自旋,直接挂起,不用消耗cpu

缺点:在大量竞争下,效率会异常低

应用:追求吞吐量,同步块执行速度较长。

synchronize的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
 
            //this,当前实例对象锁
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }
 
    public synchronized void increase(){
        j++;
    }
}

线程中断

线程中断分为两种情况:

  1. 运行时:正在运行,或者正在等待CPU调度,这种情况会抛出异常,但是不会影响运行。
  2. 等待锁阻塞时:等待获取锁,这种情况下是不会被打断的,所以死锁是不会被打断的。
  3. 睡眠阻塞时:会抛出InterruptedException异常并返回,不会继续往下执行。
  4. wait阻塞时:会抛出InterruptedException异常并返回, 不会继续往下执行。

在Java中,提供了以下3个有关线程中断的方法:

//中断线程(实例方法)
public void Thread.interrupt();
 
//判断线程是否被中断(实例方法)
public boolean Thread.isInterrupted();
 
//判断是否被中断并清除当前中断状态(静态方法)
public static boolean Thread.interrupted();

线程唤醒

所谓等待唤醒机制本篇主要指的是notify/notifyAll和wait方法,在使用这3个方法时,必须处于synchronized代码块或者synchronized方法中,否则就会抛出IllegalMonitorStateException异常,这是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因。

Synchronized的底层原理,及简介
https://www.cnblogs.com/mingyao123/p/7424911.html
再一个补充:
http://developer.51cto.com/art/201905/596986.html
原文链接:https://blog.csdn.net/qq_41701956/article/details/83660927
链接:https://www.jianshu.com/p/2ba154f275ea
原文链接:https://blog.csdn.net/rikkatheworld/article/details/88386511

发布了67 篇原创文章 · 获赞 32 · 访问量 6万+

猜你喜欢

转载自blog.csdn.net/weixin_43751710/article/details/102767541