美文网首页大数据Java
「Java多线程」内置锁(Synchronized)的前世今生

「Java多线程」内置锁(Synchronized)的前世今生

作者: Java弟中弟 | 来源:发表于2021-11-27 13:49 被阅读0次

    什么互斥和同步

    • 互斥是指某一资源同一时间只允许一个访问者对其进行访问,具有唯一性和排它性。但互斥无法控制对资源的访问顺序
    • 同步是指在互斥的基础上实现对资源的有序访问,即:也是不可以同时访问,并且还需要按照某种顺序来运行。

    什么是互斥量

    互斥量mutex

    • 是Linux提供一把 互斥锁 mutex(也称之为 互斥量 )
    • 用于对共享资源加锁,保证一时间只允许一个线程对其进行访问

    线程安全三大特性

    【Java多线程】重温并发BUG的源头之可见性、原子性、有序性

    二.为什么要用锁?

    • 锁可以解决并发执行任务执行过程中对 共享数据顺序访问、修改的场景 。比如对同时对一个账户进行 扣款 或者 转账 。

    三.什么是内置锁

    Java内置锁不需要显式的获取锁和释放锁,由JVM内部来实现锁的获取与释放。而且任何一个对象都能作为一把内置锁。在 JDK1.4及之前就是使用 内置锁Synchronized来进行线程同步控制的

    上文说,任何一个对象都能作为一把内置锁”,意味着synchronized关键字出现的地方,都有一个对象与之关联 ,具体表现为:

    • 当synchronized作用于 普通方法 时,锁对象是 this ;
    • 当synchronized作用于 静态方法 时,锁对象是 当前类的Class对象 ;
    • 当synchronized作用于 代码块 时,锁对象是 synchronized(obj)中的这个obj

    原理

    • 由 JVM 虚拟机内部实现,是基于 monitor 机制 ,每个对象都存在着一个 监视器(monitor)实与之关联,monitor的本质是依赖于底层操作系统的实现,称为内部锁或者Monitor锁。Monitor是线程私有的数据结构,每一个线程都有一个 可用monitor record列表 ,同时还有一个全局的可用列表。 每一个被锁住的对象都会和一个monitor关联 ,同时monitor中有一个 Owner字段 存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

    优缺点:

    • 优点: 即内置锁的特性,不需要使用者显示的获取锁和释放锁
    • 缺点: 线程拿不到锁就会一直等待 ,除了获取锁没有其他办法能够让其结束等待
    • 详情情况我的这篇文章 【Java多线程】了解线程的锁池和等待池概念 已经写清楚了,偷个懒,就不重复造轮子了。

    四.synchronized使用

    当多个线程同时运行时,线程的调度由操作系统决定,程序本身无法决定。因此,任何一个线程都有可能在任何指令处被操作系统暂停,然后在某个时间段后继续执行。这个时候,有个单线程场景下不存在的问题就来了: 如果 多个线程时读写共享变量 ,会出现数据不一致的问题。

    1.线程安全问题产生

    public class SyncTest1 {
    
        public static void main(String[] args) throws Exception {
    
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    
    //计数器
    class Counter {
    
        public static int count = 0;
    }
    //自增线程
    class AddThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
      Counter.count += 1; }
        }
    }
    //自减线程
    class DecThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
      Counter.count -= 1; }
        }
    }
    

    上面的代码 两个线程同时对一个int变量进行操作 ,一个加10000次,一个减10000次,最后结果应该是0,但是, 每次运行,结果实际上都是不一样的。

    连续执行三次结果

    「Java多线程」内置锁(Synchronized)的前世今生 「Java多线程」内置锁(Synchronized)的前世今生 「Java多线程」内置锁(Synchronized)的前世今生

    这是因为对变量进行读取和写入时,结果要正确,必须保证是 原子操作 。原子操作是指不能被中断的一个或一系列操作。

    实际上执行 n = n + 1 并不是一个原子操作,它的执行过程如下:

    1. 从主存中读取变量x副本到工作内存
    2. 给x加1
    3. 将x加1后的值写回主存

    我们假设 n 的值是 100 ,如果 两个线程同时执行n = n + 1 ,得到的结果很可能不是 102,而是101 ,

    原因在于: 多个线程执行时,CPU对线程的调度是随机的,我们不知道当前程序被执行到哪步就切换到了下一个线程

    • 如果 线程1 在从主内存将n=100的值同步到工作内存时,此时cpu切换到 线程2 , 线程2 也将n=100的值同步到工作内存
    • 线程1 n+=1 = 101,然后同步到主内存此时主内存为101
    • 线程2 n-=1 = 99,然后同步到主内存此时主内存为99
    • 显然由于执行顺序的不同n最终的结果可能为101也可能为99

    这说明多线程场景下,要保证逻辑正确, 即某一个线程对共享变量进行读写时,其他线程必须等待

    2.初识Synchronized

    • 通过加锁和解锁的操作,就能保证在一个线程执行期间,不会有其他线程会进入此代码块。
    • 即使在执行期线程被操作系统中断执行,其他线程也会因为无法获得锁导致无法进入此代码块。只有执行线程将锁释放后,其他线程才有机会获得锁并执行。这种 加锁和解锁之间的代码块 我们称之为 临界区(Critical Section) , 任何时候临界区最多只有一个线程能执行。
    • Java使用 synchronized关键字 对一个对象进行加锁、解锁,以保证操作的原子性。

    如何使用Synchronized

    synchronized(lockObject) { }.
    
    
    • 在使用synchronized的时候, 不必担心抛出异常 。 因为无论是否有异常,都会在synchronized结束处正确释放锁:
    public void add(int m) {
    
        synchronized (obj) {
    
            if (m < 0) {
    
                throw new RuntimeException();
            }
            this.value += m;
        } // 无论有无异常,都会在此释放锁
    }
    

    使用synchronized优化SyncTest1案例中线程不安全问题

    public class SyncTest2 {
    
        public static void main(String[] args) throws Exception {
    
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    //计数器
    class Counter {
    
        public static final Object lock = new Object();
        public static int count = 0;
    }
    //自增线程
    class AddThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
         synchronized(Counter.lock) {
      Counter.count += 1;} }
        }
    }
    //自减线程
    class DecThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
       synchronized(Counter.lock){
      Counter.count -= 1;} }
        }
    }
    

    执行结果

    「Java多线程」内置锁(Synchronized)的前世今生

    代码

    synchronized(Counter.lock) {
     //获取锁
    
      }//释放锁
    
    • 它表示用 Counter.lock实例 作为锁,2个线程在执行各自的 synchronized(Counter.lock) { ... } 代码块时, 必须先获得锁,才能进入代码块进行。 执行结束后,在synchronized语句块结束会自动释放锁。 这将会导致对Counter.count变量进行读写就 不能同时进行 。无论运行多少次,最终结果都是0。

    synchronized解决了多线程同步访问共享变量的有序性问题。但它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外 加锁和解锁需要消耗一定的时间,所以, synchronized会降低程序的执行效率。

    3.错误使用Synchronized的案例

    3.1.案例1

    public class Main {
    
        public static void main(String[] args) throws Exception {
    
            AddThread add = new AddThread();
            DecThread dec = new DecThread();
            add.start();
            dec.start();
            add.join();
            dec.join();
            System.out.println(Counter.count);
        }
    }
    
    class Counter {
    
        public static final Object lock1 = new Object();
        public static final Object lock2 = new Object();
        public static int count = 0;
    }
    
    class AddThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock1) {
    
                    Counter.count += 1;
                }
            }
        }
    }
    
    class DecThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock2) {
    
                    Counter.count -= 1;
                }
            }
        }
    }
    

    执行结果

    「Java多线程」内置锁(Synchronized)的前世今生

    结果并不是0,这是因为 2个线程各自的synchronized锁住的不是同一个对象! 这使得2个线程各自都可以同时获得锁: 因为JVM只保证同一个锁在任意时刻只能被一个线程获取,但两个不同的锁在同一时刻可以被2个线程分别获取 。 使用synchronized的时候,获取到的是哪个锁非常重要。锁对象如果不对,代码逻辑就不对。

    3.2.案例2

    public class SyncTest3 {
    
        public static void main(String[] args) throws Exception {
    
            Thread [] ts = new Thread[] {
      new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread() };
            for (Thread t : ts) {
    
                t.start();
            }
            for (Thread t : ts) {
    
                t.join();
            }
            System.out.println(Counter.studentCount);
            System.out.println(Counter.teacherCount);
        }
    }
    
    class Counter {
    
        public static final Object lock = new Object();
        public static int studentCount = 0;
        public static int teacherCount = 0;
    }
    
    class AddStudentThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock) {
    
                    Counter.studentCount += 1;
                }
            }
        }
    }
    
    class DecStudentThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock) {
    
                    Counter.studentCount -= 1;
                }
            }
        }
    }
    
    class AddTeacherThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock) {
    
                    Counter.teacherCount += 1;
                }
            }
        }
    }
    
    class DecTeacherThread extends Thread {
    
        public void run() {
    
            for (int i=0; i<10000; i++) {
    
                synchronized(Counter.lock) {
    
                    Counter.teacherCount -= 1;
                }
            }
        }
    }
    

    执行结果

    「Java多线程」内置锁(Synchronized)的前世今生
    • 上面 4个线程 对 两个共享变量 分别进行读写操作,但是使用的锁都是 Counter.lock对象 ,这就造成了 原本可以并发执行的Counter.studentCount += 1和Counter.teacherCount += 1无法并发执行了 , 执行效率大大降低
    • 实际上,需要同步的线程可以分成2组: AddStudentThread和DecStudentThread , AddTeacherThread和DecTeacherThread ,组之间不存在竞争关系,因此,应该使用2个不同的锁
    public class SyncMultiTest3 {
    
        public static void main(String[] args) throws Exception {
    
            //创建线程
            Thread[] ts = new Thread[]{
     new AddStudentThread(), new DecStudentThread(), new AddTeacherThread(), new DecTeacherThread()};
            //启动线程
            for (Thread t : ts) {
    
                t.start();
            }
            //优先子线程先执行
            for (Thread t : ts) {
    
                t.join();
            }
            //最后打印执行结果
            System.out.println(Counter.studentCount);
            System.out.println(Counter.teacherCount);
        }
    }
    
    //计数器
    class Counter {
    
        public static final Object lockTeacher = new Object();//学生线程锁对象
        public static final Object lockStudent = new Object();//老师线程锁对象
        public static int studentCount = 0;
        public static int teacherCount = 0;
    }
    
    //增加学生数量线程
    class AddStudentThread extends Thread {
    
        public void run() {
    
            for (int i = 0; i < 10000; i++) {
    
                synchronized (Counter.lockStudent) {
    
                    Counter.studentCount += 1;
                }
            }
        }
    }
    
    //减少学生数量线程
    class DecStudentThread extends Thread {
    
        public void run() {
    
            for (int i = 0; i < 10000; i++) {
    
                synchronized (Counter.lockStudent) {
    
                    Counter.studentCount -= 1;
                }
            }
        }
    }
    
    //增加老师数量线程
    class AddTeacherThread extends Thread {
    
        public void run() {
    
            for (int i = 0; i < 10000; i++) {
    
                synchronized (Counter.lockTeacher) {
    
                    Counter.teacherCount += 1;
                }
            }
        }
    }
    
    //减少老师数量线程
    class DecTeacherThread extends Thread {
    
        public void run() {
    
            for (int i = 0; i < 10000; i++) {
    
                synchronized (Counter.lockTeacher) {
    
                    Counter.teacherCount -= 1;
                }
            }
        }
    }
    

    执行结果

    「Java多线程」内置锁(Synchronized)的前世今生

    3.3.案例3

    JVM规范定义了几种原子操作:

    • 基本类型(long和double除外)赋值,例如:int n = m;

    • 引用类型赋值,例如:List list = anotherList。

    • long和double是64位数据,JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把long和double的赋值作为原子操作实现的。

    1. 单条原子操作的语句不需要同步。例如:
    public void set(int m) {
    
        synchronized(lock) {
    
            this.value = m;
        }
    }
    

    就不需要同步

    //引用类型赋值
    public void set(String s) {
    
        this.value = s;
    }
    
    1. 如果是多行赋值语句,就必须保证是同步操作
    class Pair {
    
        int first;
        int last;
        public void set(int first, int last) {
    
            synchronized(this) {
    
                this.first = first;
                this.last = last;
            }
        }
    }
    

    有些时候,通过一些巧妙的转换,可以把非原子操作变为原子操作。例如,上述代码如果改造成:

    class Pair {
    
        int[] pair;
        public void set(int first, int last) {
    
            int[] ps = new int[] {
      first, last };
            this.pair = ps;
        }
    }
    

    就不再需要同步,因为 this.pair = ps 是 引用赋值的原子操作 。而语句: int[] ps = new int[] { first, last }; ,这里的 ps是方法内部定义的局部变量 , 每个线程都会有各自的局部变量,互不影响,并且互不可见,并不需要同步。

    3.4.小结

    1. 多线程 同时读写共享变量时 ,会造成逻辑错误,因此需要通过 synchronized 同步;
    2. 同步的本质就是给指定对象加锁 ,加锁后才能继续执行后续代码
    3. 注意 加锁对象必须是同一个实例
    4. JVM定义的单个原子操作不需要同步

    五.Jvm对synchronized的优化

    在 JDK1.6 之前, syncronized 是一把 重量级锁,在 JDK 1.6之后为了 减少获得锁和释放锁带来的性能消耗,会有一个 锁升级的过程,给Synchronized加入了 "偏向锁、自旋锁、轻量级锁"的特性,这些优化使得Synchronized的性能在某些场景下与ReentrantLock的性能持平

    1. syncronized一共有 4 种锁状态,级别从低到高依次是: 无锁->偏向锁->轻量级锁->重量级锁,这几个状态会 随着竞争情况逐渐升级。
    2. 锁可以升级但不能降级 ,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略, 目的是为了提高获得锁和释放锁的效率。

    1.Java对象内存结构

    对象在堆内存中存储的布局分为3块

    「Java多线程」内置锁(Synchronized)的前世今生

    如上图所示, 以Hotspot虚拟机为例, 在实例化一个对象后,在 Java内存中的布局 可分为 3 块:

    1.对象头包括2部分(ObjectHeader):

    • 对象自身的运行时数据( MarkWord 标记字段 )存储对象hashCode、对象GC分代年龄、锁类型标记 、偏向锁线程 ID 、 CAS 锁指向线程 LockRecord 的指针等, synconized 锁的机制与这个部分( MarkWord )密切相关 ,用 MarkWord 中 最低的三位代表锁的状态 ,其中一位是偏向锁位,另外两位是普通锁位。在运行期间, Mark Word 里面存储的数据会随着 锁标志位 的变化而变化。 MarkWord 可能变为存储以下 5 种数据,如下图所示可以看到当对象状态为 偏向锁 时,Mark Word存储的是偏向的 线程ID ;当对象状态为 轻量级锁 时,Mark Word存储的是 指向线程栈中Lock Record(锁记录)的指针当对象状态为 重量级锁 时,Mark Word为 指向堆中的monitor对象的指针
    • 对象类型指针( ClassPointer )对象指向它的类元数据的指针 、 JVM 就是通过它来确定是哪个 Class 的实例。

    2.实例数据区域(InstanceData)

    • 此处存储的是对象真正有效的信息 ,比如对象中所有变量的内容,,其大小由各个变量的大小决定,比如:byte和boolean是1个字节,short和char是2个字节,int和float是4个字节,long和double是8个字节,reference是4个字节

    3.对齐填充区域(Padding)

    • JVM 的实现 HostSpot 规定 对象的起始地址必须是 8 字节的整数倍 。现在 64 位的 OS 往外读取数据的时候一次性读取 64bit 整数倍 的数据,也就是 8 个字节 ,所以 HotSpot 为了高效读取对象,就做了 "对齐" , 如果一个对象实际占的内存大小不是 8byte 的整数倍时,就"补位"到 8byte 的整数倍。所以对齐填充区域的大小不是固定的。

    2.JDK1.6中JVM对Synchronized的优化

    锁消除和锁粗化,适应性自旋是虚拟机对低效的锁操作而进行的一个优化。

    2.1.锁消除(Lock Elimination)

    锁削除是指JVM编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

    • 简单来说,在编译期间,JVM会清除一些使用了同步,但同步块中没有涉及共享数据的锁,从而减少多余的同步。

    如:使用StringBuffer的append方法,因为append方法需要判断对象是否被占用,而如果代码不存在锁的竞争,那么这部分的性能消耗是无意义的。于是虚拟机在即时编译的时候就会将上面代码进行优化,也就是锁消除。

    那么虚拟机如何判断不存在同步情况呢?通过 逃逸分析 。可见下面伪代码

    public static String createStringBuffer(String str1, String str2) {
    
            StringBuffer sb= new StringBuffer();
            sb.append(str1);// append方法是同步操作
            sb.append(str2);
            return sBuf.toString();// toString方法是同步操作
    }
    
    • 可以看到 sb对象 使用的范围仅仅只在方法栈 createStringBuffer 中,因为 return 回去的对象是一个 新的String对象 。
    • 也就是说 sb对象 是不会 逃逸 出去从而 被其他线程访问到 ,那就可以把它们当作 栈 上的数据对待,认为它们是 线程私有 ,同步锁无需进行。

    2.2.锁粗化(Lock Coarsening)

    若有一系列操作,反复地对同一把锁进行加锁和解锁操作,编译器会扩大这部分代码的同步块的边界,从而只使用一次上锁和解锁操作。。

    public static StringBuffer createStringBuffer(String str1, String str2) {
    
        StringBuffer sBuf = new StringBuffer();
        sBuf.append(str1);// append方法是同步操作
        sBuf.append(str2);// append方法是同步操作
        sBuf.append("abc");// append方法是同步操作
        return sBuf;
    }
    
    • 当 频繁的对sBuf进行加锁、解锁 ,会造成性能上的损失。 如果虚拟机探测到有一系列连续操作都是对同一对象加锁,将会把加锁同步的范围扩展到整个操作的最外部,也就是在第一个和最后一个append操作之后
    • 用下面伪代码对锁粗化进行说明
    for(int i=0;i<100000;i++){
    
        synchronized(this){
    
            do();  
        }
    }  
    
    //在锁粗化之后运行逻辑如下列代码
    synchronized(this){
    
        for(int i=0;i<100000;i++){
    
            do();
        }    
    }
    

    2.3.适应性自旋锁(Adaptive Spinning)

    背景:在许多场景中,同步资源的锁定时间很短,为了这一小段时间去阻塞或唤醒一个线程的时间可能比用户代码执行的时间还要长。为了让当前线程“稍等一下”,我们可以让线程进行自旋,如果在自旋过程中占用同步资源的线程已经释放了锁,那么当前线程就可以不进入阻塞而直接获取同步资源,从而避免切换线程的开销。

    自旋锁(spinlock):即当一个线程在获取锁的时候,如果锁已经被其它线程获取,不是立即阻塞线程。那么该线程将 循环等待,然后 不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。尝试获取锁的线程不会立即 阻塞(放弃CPU时间片),采用 循环的方式尝试获取锁!

    • 优点 : 不会使线程进入阻塞状态(放弃CPU时间片),通过占用CPU时间来避免线程切换带来的开销 ,避免了线程在尝试获得锁失败后,在 “挂起-再次尝试” 之间,不断上下文切换造成的资源浪费。
    • 缺点 : 自旋等待虽然避免了线程切换的开销,但如果自旋锁获取锁的时间太长,会造成后面的线程 CPU资源耗尽 ,因此自旋锁只适用于 锁的占用时间较短 的场景(自旋锁是不公平的)。如果持有锁的线程不能很快释放锁,线程CPU的占用时间(自旋过程)会过长,反而使得效率变低,性能下降。因此 虚拟机限制了自旋次数(默认是10次,JDK1.6中通过-XX:+UseSpinning开启,可以使用-XX:PreBlockSpin来更改,JDK1.7后,去掉此参数,由JVM控制),如果自旋超过了限定次数,虚拟机会将线程挂起,让出cpu资源。
    「Java多线程」内置锁(Synchronized)的前世今生

    什么是自适应自旋锁:即: 自旋的次数不再固定 , 由前一次在 同一个锁上的自旋时间 及 锁的拥有者的状态来决定 。 来计算出一个较为合理的本次自旋等待时间。

    如果 线程1 自旋等待刚刚成功获得过锁,并且占有锁的 线程2 正在运行中,那么JVM就会认为线程1 这次自旋也很有可能再次成功,进而允许线程1进行更多次的自旋等待。反过来说,如果某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费CPU资源。

    2.4.简述偏向锁

    • 本质: 在无竞争情况下把整个同步都消除掉,甚至连CAS操作都不做了,只需判断 Mark Word 中的一些值是否正确就行 。进一步提升程序性能。
    • 与轻量级锁的区别:轻量级锁是在无竞争的情况下使用 CAS操作 来代替 互斥同步((阻塞) 的使用,从而实现同步;而偏向锁是 在无竞争的情况下完全取消同步 。
    • 与轻量级锁的相同点:它们都是 乐观锁 ,都认为同步期间不会有其他线程竞争锁。
    • 原理:当线程请求到锁对象后,将锁对象的状态标志位改为 01 , 即进入 “偏向锁状态” 。然后使用 CAS操作 将 线程ID 记录在锁对象的 Mark Word 中。该线程再次请求锁时,无需再做任何同步操作,即获取锁的过程只需要检查 MarkWord 的锁标记位为偏向锁 以及 当前线程 Id 等于 Mark Word 的 ThreadId 即可 直接接进入同步块,连CAS操作都不需要。但是,一旦有第2个线程需要竞争锁,那么偏向模式立即结束, 进入 “轻量级锁” 的状态。
    • 优点:偏向锁可以 提高有同步但没有竞争的程序性能 。但是如果锁对象时常被多个线程竞争,那偏向锁就是 多余 的。偏向锁JDK1.6之后默认开启。参数开启方式:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 在 JDK1.8 中,其实默认是轻量级锁 ,但如果设定了 -XX:BiasedLockingStartupDelay = 0,那在对一个 Object 做 syncronized 的时候,会立即上一把偏向锁。tips:偏向锁的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会 偏向于第一个获得它的线程 ,如果在当前线程的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步

    2.5.简述轻量级锁

    • 背景:『轻量级锁』是相对于『重量级锁』而言的,即使用操作系统来实现的传统锁
    • 本质: 在无竞争的情况下使用CAS操作去取代同步使用的 。
    • 轻量级锁与重量级锁的区别:重量级锁是一种 悲观锁 ,它认为总是有多个线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用 互斥同步(阻塞) 来保证线程的安全;而轻量级锁是一种乐观锁,它认为锁存在竞争的概率比较小,所以它不使用互斥同步,而是 使用CAS操作来获得锁 ,这样能 减少传统的重量级锁使用操作系统 『』 带来的性能开销。
    • 实现原理:对象头称为『 Mark Word 』,对象处于不同的状态下, Mark Word 中存储的信息也所有不同。Mark Word中有个 标志位 用来表示当前对象所处的状态。当线程请求锁时,若该锁对象的Mark Word中标志位为 01 ( 无锁状态 ),则在该线程的栈帧中创建一块名为 『锁记录Lock Record』 的空间,然后将 锁对象的Mark Word拷贝至该空间 ; 最后通过 CAS操作 将锁对象的 Mark Word 更新为指向Lock Record的指针若CAS更新指针 成功 ,则轻量级锁的上锁过程成功;若CAS更新指针 失败 ,再判断 当前线程是否已经持有了该轻量级锁 ;若已经持有, 直接进入同步块 ;若尚未持有,则表示该锁已经被其他线程占用, 此时轻量级锁就要膨胀成 “重量级锁”。
    • 轻量级锁比重量级锁性能更高的前提 :在轻量级锁被占用的整个同步周期内,不存在其他线程的竞争 。若在该过程中一旦有其他线程竞争,那么就会 膨胀成重量级锁 ,从而除了使用以外,还额外发生了 CAS操作 , 因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。如果执行同步块的时间 比较短 ,那么多个线程之间执行使用轻量级锁 交替执行 。如果执行同步块的时间 比较长 ,那么多个线程之间刚开始使用轻量级锁,后面会 膨胀为重量级锁 。(因为执行同步块的时间长,线程 CAS 自旋获得轻量级锁失败后就会锁膨胀)

    2.6.简述重量级锁

    重量级锁是一种 悲观锁,它认为总是有多个线程要竞争锁,所以它每次处理共享数据时,不管当前系统中是否真的有线程在竞争锁,它都会使用 互斥同步(阻塞)来保证线程的安全;

    • 会发生 上下文切换 ,CPU 状态从 用户态转换为内核态 执行操作系统提供的,所以系统开销比较大,响应时间也比较缓慢。

    3.锁升级

    3.1.什么是锁升级

    锁升级的过程其实就是对象头中的 Mark Word 数据结构改变的过程。 是不可逆转的。

    「Java多线程」内置锁(Synchronized)的前世今生 「Java多线程」内置锁(Synchronized)的前世今生

    1.默认是无锁状态

    2.偏向锁的判断

    • 在 JDK1.8 中,其实默认是轻量级锁 ,但如果设定了 -XX:BiasedLockingStartupDelay = 0 ,那在无竞争的时候对一个 Object 做 syncronized 的时候,会立即上一把偏向锁。 当处于偏向锁状态时, MarkWork 会记录当前线程 ID

    3.升级到轻量级锁的判断

    • 一旦有 第2个线程 参与到偏向锁竞争时,会先判断 MarkWork 中保存的 线程 ID 是否与这个线程 ID 相等如果不相等,会立即撤销偏向锁,升级为轻量级锁 。每个线程在自己的 线程栈中 生成一个 LockRecord ( LR ) ,然后每个线程通过 CAS (自旋) 的操作将锁对象头中的 MarkWork 设置为指向自己的 LR 的指针, 哪个线程设置成功,就意味着获得锁 。

    4.升级到重量级锁的判断

    • 如果锁竞争加剧( 如线程自旋次数或者自旋的线程数超过某阈值, JDK1.6 之后,由 JVM 自己控制该规则 ),就会升级为重量级锁 。此时就会向操作系统申请资源,线程挂起,进入到操作系统内核态 的 等待队列 中,等待操作系统调度,然后映射回 用户态在重量级锁中,由于 需要做内核态到用户态的转换 ,而这个过程中需要消耗较多时间,有可能比用户执行代码的时间还要长。也就是"重"的原因之一。重量级锁通过是对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的实现实现.

    3.2.锁升级的四种锁状态的思路及特点

    无锁、偏向锁 、 轻量级锁、 重量级锁都是指 synchronized在某种场景下的状态 ,整体的锁状态升级流程如下:

    「Java多线程」内置锁(Synchronized)的前世今生

    Mark Word在不同锁状态下的结构

    「Java多线程」内置锁(Synchronized)的前世今生

    无锁 VS 偏向锁 VS 轻量级锁 VS 重量级

    「Java多线程」内置锁(Synchronized)的前世今生

    1.无锁状态

    无锁没有对共享资源进行加锁,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

    • 无锁的特点 :线程会不断自旋的尝试修改共享资源。如果没有冲突就修改成功并退出,否则就会继续循环尝试。如果有多个线程修改同一个值,必定会有一个线程能修改成功,而其他修改失败的线程会不断重试直到修改成功。
    • CAS应用即是无锁的实现。无锁无法全面代替有锁,但无锁在某些场合下的性能是非常高的。

    2.偏向锁状态

    偏向锁是指一段同步代码一直被 同一个线程所访问,那么该线程会自动获取锁, 减少同一线程获取锁的代价,省去了大量有关 锁申请的操作。

    • 在大多数情况下,锁总是由 同一线程多次获得 ,不存在多线程竞争,所以出现了 偏向锁 。其目的就是 在只有一个线程执行同步代码块时能够提高性能。

    • 当一个线程访问同步代码块并获取锁时,会在 Mark Word 里存储 锁偏向的线程ID 。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向当前线程的偏向锁 。

    • 引入偏向锁是为了在 无多线程竞争 的情况下尽量减少不必要的 轻量级锁执行路径 ,因为轻量级锁的获取及释放依赖多次 CAS原子指令 ,而偏向锁只需要在置换 ThreadID 的时候依赖一次CAS原子指令即可。CAS 原子指令虽然相对于重量级锁来说开销比较小但还是存在非常可观的本地延迟( 因为 CAS 的底层是利用 LOCK 指令 + cmpxchg 汇编指令 来保证原子性的当该线程再次请求锁时,无需再做任何同步操作,只需要检查 MarkWord 的锁标记位为偏向锁 以及 当前线程 Id 等于 Mark Word 的 ThreadId 即可

    • 偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态。撤销偏向锁后恢复到无锁(标志位为“01”)或轻量级锁(标志位为“00”)的状态。

    3.轻量级锁状态

    是指 当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过 自旋的形式尝试获取锁,不会阻塞,从而提高性能。

    • 在代码进入同步块的时候,如果同步对象锁状态为 无锁状态(锁标志位为“01”状态,是否为偏向锁为“0”) ,JVM首先将在当前线程的栈帧中建立一个名为 锁记录(Lock Record) 的空间,用于存储锁对象目前的 Mark Word的拷贝 ,然后 拷贝对象头中的Mark Word复制到锁记录中
    • 拷贝成功后,JVM将使用 CAS 操作尝试将 对象的Mark Word更新为指向 Lock Record 的指针,并将 Lock Record 里的 owner指针 指向对象的 Mark Word 。如果这个更新指针操作成功 ,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为 “00” ,表示此对象处于 轻量级锁定状态。如果更新操作失败,JVM首先会检查 对象的Mark Word是否指向当前线程的栈帧 ,如果指向就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明多个线程竞争锁。
    • 若 当前只有一个等待线程 ,则该线程通过 自旋 进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为 重量级锁 。

    4.重量级锁状态

    升级为重量级锁时,锁标志的状态值变为 “10” ,此时Mark Word中存储的是 指向重量级锁的指针 ,此时 等待锁的线程 都会进入 阻塞状态 。

    4.加锁和解锁的过程

    4.1.加锁的过程

    主要分为 3 步:

    1. 在线程进入同步块的时候,如果同步对象状态为 无锁状态 (锁标志为 01 ),虚拟机首先将在 当前线程 的栈帧中建立一个名为 锁记录( Lock Record) 的空间,用来存储锁对象目前的 Mark Word 的拷贝 。拷贝成功后,虚拟机将使用 CAS 操作尝试 将对象的 Mark Word 更新为指向 Lock Record 的指针 ,并将 Lock Record 里的 owner 指针 指向锁对象的 Mark Word 。如果更新成功,则执行 2,否则执行 3。
    「Java多线程」内置锁(Synchronized)的前世今生
    1. 如果这个 更新动作成功 了,那么这个线程就拥有了该对象的锁,并且锁对象的 Mark Word 中的锁标志位设置为 "00" ,即表示此对象处于 轻量级锁定状态 ,这时候虚拟机 线程栈与堆中锁对象的对象头的状态 如图所示。
    「Java多线程」内置锁(Synchronized)的前世今生

    3. 如果这个 更新操作失败 了,虚拟机首先会检查锁对象的 Mark Word 是否指向 当前线程的栈帧, 如果是 就说明当前线程已经拥有了这个对象的锁,那就可以 直接进入同步块继续执行 。 否则说明多个线程竞争锁 ,轻量级锁就要膨胀为 重要量级锁 ,锁标志的状态值变为 "10" , Mark Word 中存储的就是指向重量级锁的指针 ,后面等待锁的线程也要进入 阻塞状态。 而当前线程便尝试使用自旋来获取锁。 自旋失败后膨胀为重量级锁,被阻塞。

    4.2.解锁的过程

    • 因为虚拟机线程栈帧中的 Displaced Mark Word(锁记录LR) 是最初的 无锁状态时 的数据结构,所以 用它来替换对象头中的 Mark Word 就可以释放锁 。 如果锁 已经膨胀为重量级 ,此时是 不可被替换 的,所以替换失败, 唤醒被挂起的线程 。

    5.锁的优缺点

    综上所述:

    • 偏向锁 通过对比 Mark Word 解决 加锁 问题,避免执行CAS操作。
    • 轻量级锁 是通过用 CAS操作 和 自旋 来解决 加锁 问题,避免线程阻塞和唤醒而影响性能。
    • 重量级锁 是 将除了拥有锁的线程以外的线程都阻塞 。

    Java中常见使用 synchroinzed 的地方有:

    • ConcurrentHashMap (jdk 1.8)
    • HashTable
    • StringBuffer

    相关文章

      网友评论

        本文标题:「Java多线程」内置锁(Synchronized)的前世今生

        本文链接:https://www.haomeiwen.com/subject/zcafxrtx.html