三、并发编程之synchronized详解

设计synchronized同步器的意义

在多线程编程中,可能会出现多个线程访问同一个共享,可变的资源,这个资源就是所谓的临界资源,这个资源可能是,变量,对象,文件等
共享:资源可同时被多个线程访问
可变:资源在生命周期内可以被改变

由此引发的问题:
由于线程的执行是不可控的,所以需要同步机制来协同对可变资源的访问。

那怎么去解决此类的问题:
实际上,我们所有的并发模式在解决线程安全问题时,采用的都是序列化的访问临界资源,就是在同一时刻,只有一个线程访问临界资源,也称作同步互斥访问

Java提供了两种同步互斥访问:synchronized和lock
本质其实就是加锁,加锁的目的就是序列化的访问临界资源,同一时刻只能有一个线程访问临界资源,但是有一点要区别的是,多线程情况下,在方法内部的变量存在月线程的私有栈中,因此不具有共享性,所以不会出现线程不安全的情况

synchronized原理详解

synchronized的内置锁是一种对象锁(锁的是对象而并非引用),锁的粒度是对象,用于对共享资源对互斥访问,也是可重入的
加锁方式:

  • 同步实例方法,锁的是实例对象
  • 同步类方法,锁的是类对象
  • 同步代码块,锁的是括号里的对象

synchronized的底层原理
synchronized是基于JVM的内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块的同步,监视器锁的实现是依赖底层操作系统的Mutex Lock (互斥锁)实现,它是一个重量级锁性能较低,当然,JDK1.5过后做了重大的优化,如锁粗化,锁消除,轻量级锁,偏向锁,适应性自旋等技术来减少锁操作的开销,内置锁的并发性能基本与lock持平

synchronized关键字被编译成字节码后被反应成monitorenter 和monitorexit两条指令分别在同步快逻辑代码的其实位置与结束位置
在这里插入图片描述
每个同步对象都有一个自己的Monitor(监视器锁),加锁过程如下所示:
在这里插入图片描述
那么有个问题来来,我们知道synchronized加锁加在对象上,那对象是如何记录锁的状态的呢?
答案是锁状态是被记录在对象头(Mark Word)中,下面我们认识下对象的内存布局:
HotSpot虚拟机的内存布局,内存可以分为三块区域,对象头,实例数据,对齐填充。

  • 对象头:比如hash码,对象所属年代,对象锁,锁状态标志,偏向锁ID,偏向时间,数组对象等
  • 实例数据:创建对象时对象成员变量,方法等
  • 对齐填充:对象大小必须是8字节对整数倍
    在这里插入图片描述
    对象头
    HotSpot虚拟机的 包括两部分信息,第一部分是 ”,用于 存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标 志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和 64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官 方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固 定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用 自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于 存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻 量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。
    在这里插入图片描述
    但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过 Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数 组的大小,所以用一块来记录数组长度。
    对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟 机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内 存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说, Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):
    在这里插入图片描述
    锁的膨胀升级过程
    锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的 竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单 向的,也就是说只能从低到高升级,不会出现锁的降级。下图为锁的升级全过 程:
    在这里插入图片描述

偏向锁
偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过 研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多 次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引 入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模 式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需 再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从 而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效 果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激 烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相 同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏 向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁

轻量级锁
倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种 称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同 步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应 的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就 会导致轻量级锁膨胀为重量级锁

自旋锁
轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进 行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都 不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实 现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对 比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程 可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为 自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环 后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作 系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。 最后没办法也就只能升级为重量级锁了。

锁消除
消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编 译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编 译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种 方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的 append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量, 并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情 景,JVM会自动将其锁消除。

逃逸分析

使用逃逸分析,编译器可以对代码做如下优化:
一、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可 以不考虑同步。
二、将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远 不会逃逸,对象可能是栈分配的候选,而不是堆分配。
三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问 到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
是不是所有的对象和数组都会在堆内存分配空间?
不一定
在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, ­ XX:+DoEscapeAnalysis : 表示开启逃逸分析 ­XX:­DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定­XX:­ DoEscapeAnalysis

代码演示

public class StackAllocTest {
    
    

    /**
     * 进行两种测试
     * 关闭逃逸分析,同时调大堆空间,避免堆内GC的发生,如果有GC信息将会被打印出来
     * VM运行参数:-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     *
     * 开启逃逸分析
     * VM运行参数:-Xmx4G -Xms4G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError
     *
     * 执行main方法后
     * jps 查看进程
     * jmap -histo 进程ID
     *
     */

    public static void main(String[] args) {
    
    
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
    
    
            alloc();
        }
        long end = System.currentTimeMillis();
        //查看执行时间
        System.out.println("cost-time " + (end - start) + " ms");
        try {
    
    
            Thread.sleep(100000);
        } catch (InterruptedException e1) {
    
    
            e1.printStackTrace();
        }
    }


    private static Student alloc() {
    
    
        //Jit对编译时会对代码进行 逃逸分析
        //并不是所有对象存放在堆区,有的一部分存在线程栈空间
        Student student = new Student();
        return student;
    }

    static class Student {
    
    
        private String name;
        private int age;
    }
}

1,本地mac环境就是1.8的jdk,是默认开启的逃逸分析,使用-XX:-DoEscapeAnalysis关闭逃逸分析,这里我们先创建500000个对象,看真正在堆里面有多少对象
在这里插入图片描述
这里是创建了500000个对象在堆上

2,取消关闭逃逸分析,再次通过jps看真正在堆里面有多少对象
在这里插入图片描述
由此可见这里并没有创建500000个对象,说明进行了逃逸分析,一部分对象在栈上分配了

猜你喜欢

转载自blog.csdn.net/qq_39513430/article/details/109491852