• synchronized详解


    多线程编程中,有可能会出现多个线程同时访问同一个共享可变资源的情况;这种资源可能是:对象、变量、文件等。

    由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问,那么我们怎么解决线程并发安全问题?

      实际上,所有的并发模式在解决线程安全问题时,采用的方案都是 序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。

    Java 中,提供了两种方式来实现同步互斥访问:synchronized Lock

    synchronized 关键字:对于不同线程之间看到的是有序的,但是对于synchronized代码块之内的代码有可能会发生指令重排。

    一、锁

    1、同步器的本质就是加锁

    加锁目的:序列化访问临界资源,即同一时刻只能有一个线程访问临界资源(同步互斥访问)

    不过当多个线程执行一个方法时,该方法内部的局部变量并不是临界资源,因为这些局部变量是在每个线程的私有栈中,因此不具有共享性,不会导致线程安全问题。

    2、锁类型

    隐式锁:Synchronized加锁机制是Jvm内置锁,不需要手动加锁与解锁Jvm会自动加锁跟解锁。

    显式锁:Lock;例如:ReentrantLock,实现juc里的Lock接口,实现是基于AQS实现,需要手动加锁跟解锁ReentrantLock lock(), unlock();

    3、锁体系

    (1)synchronized的锁升级:无锁,偏向锁,轻量级锁,重量级锁; 

    (2)共享锁:读锁; 排他锁:写锁;

    二、synchronized原理详解  

      synchronized内置锁是一种对象锁(锁的是对象而非引用),作用粒度是对象,可以用来实现对临界资源的同步互斥访问,是可重入的。

    1、加锁方式

    (1)同步静态方法:锁是类对象

    (2)同步普通方法:锁是实例对象;(在spring容器中,Bean必须是单例的,否则加的synchronized没有什么作用)

    (3)同步代码块: 锁是括号里面的对象;

    扩展:使用其他方式加锁:

    (1)UnsafeInstance.reflectGetUnsafe().monitorEnter(obj); 和 UnsafeInstance.reflectGetUnsafe().monitorExit(obj);

    (2)ReentrantLock;

    2、synchronized底层原理  

      synchronized是基于JVM内置锁实现,通过内部对象Monitor(监视器锁)实现,基于进入与退出Monitor对象实现方法与代码块同步,监视器锁的实现依赖底层操作系统的Mutex lock(互斥锁)实现,它是一个重量级锁性能较低。

      当然,JVM内置锁在1.5之后版本做了重大的优化,如锁粗化(Lock Coarsening)、锁消除(Lock Elimination)、轻量级锁(Lightweight Locking)、偏向锁(Biased Locking)、适应性自旋(Adaptive Spinning)等技术来减少锁操作的开销,内置锁的并发性能已经基本与Lock持平。

    synchronized关键字被编译成字节码后会被翻译成 monitorenter 和 monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。

     每一个对象被创建之后,都会在JVM内部维护一个与之对应的monitor监视器锁(ObjectMonitor对象)。当线程并发的去访问同步的代码块时,碰到了 monitorenter 指令的时候,这些线程要先去竞争这个对象的monitor对象。

    ObjectMonitor对象的部分代码:

    synchronized加锁加在对象上,对象是如何记录锁状态的呢?

        锁状态是被记录在每个对象的对象头(Mark Word)中,下面我们一起认识一下对象的内存布局。

    对象的内存布局  

      HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

    • 对象头:比如 hash码,对象所属的年代,对象锁,锁状态标志,偏向锁(线程)ID,偏向时间,数组长度(数组对象)等;
    • 实例数据:即创建对象时,对象中成员变量,方法等;
    • 对齐填充:对象的大小必须是8字节的整数倍;

    对象头

    HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。这些信息随着锁的膨胀升级而不同,以 32位JVM为例:

    实例对象内存中存储在哪?

    如果实例对象存储在堆区时:实例对象内存存在堆区,实例的引用存在栈上,实例的元数据class存在方法区或者元空间;

    Object实例对象一定是存在堆区的吗?

    不一定,如果实例对象没有线程逃逸行为。JIT会进行优化。

    逃逸分析

    使用逃逸分析,编译器可以对代码做如下优化:

    (1)同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

    (2)将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

    (3)分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

    在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, ­XX:+DoEscapeAnalysis : 表示开启逃逸分析 ­XX:­DoEscapeAnalysis : 表示关闭逃逸分析 从jdk1.7开始已经默认开始逃逸分析,如需关闭,需要指定­XX:­DoEscapeAnalysis

    /**
    * 进行两种测试
    * 关闭逃逸分析,同时调大堆空间,避免堆内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 class Demo{
        StringBuffer stb = new StringBuffer();
        
        public void test1() {
            stb.append("1");
            stb.append("2");
            stb.append("3");
            stb.append("4");
        }
    }

    StringBuffer 是线程安全的,它的 append() 方法上增加了 synchronized, test1() 方法中连续调用了4次 append() 方法,本应该是会有4次的加锁过程,但是JVM做了优化只需要做1次加锁,如下代码所示,这就是锁的粗化

    public void test1() {
        synchronized(this) {
            stb.append("1");
            stb.append("2");
            stb.append("3");
            stb.append("4");
        }
    }

    锁的清除

      消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间。如下的 test2() 方法,每次的锁对象都会去 new,就不存在共享资源竞争关系了,JVM会对其优化自动去除锁。

    public void test2(){
        //jvm的优化,JVM不会对同步块进行加锁
        synchronized (new Object()) {
            //伪代码:很多逻辑
            //jvm会进行逃逸分析
            System.out.println("-----");
        }
    }

    三、JVM内置锁的优化升级

    JDK1.6版本之后对synchronized的实现进行了各种优化,如自旋锁、偏向锁和轻量级锁并默认开启偏向锁。

    开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

    关闭偏向锁:-XX:-UseBiasedLocking

      锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级。

    1、偏向锁(单线程访问的场景)

      在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

      对于锁竞争比较激烈的场合,就不能使用偏向锁了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,先升级为轻量级锁。

    2、轻量级锁(线程的竞争不激烈,线程交替执行)

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

    3、自旋锁

    自旋不会丢弃CPU的使用权

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

      自适应自旋:JDK1.7之后,JVM会根据上次拿到锁自旋的次数去进行调整。若第一次自旋的次数为10还没有拿到,则第二次可能会调整为8;若第一次自旋次数为10就拿到了,则第二次的自旋次数可能调整为13;

     四、锁的升级膨胀过程

     

     

     锁的升级膨胀:https://www.cnblogs.com/JonaLin/p/11571482.html

  • 相关阅读:
    c语言 ,回调函数[个人理解]
    MFC、C++ 、Windows编程高手
    c++, 虚基派生 : 共同基类产生的二义性的解决办法
    c++,命名空间(namespace)
    c++,纯虚函数与抽象类
    c++ ,protected 和 private修饰的构造函数
    c++ 虚析构函数[避免内存泄漏]
    c++,虚函数
    c++,类的组合
    GPU与CPU的区别
  • 原文地址:https://www.cnblogs.com/yufeng218/p/13028549.html
Copyright © 2020-2023  润新知