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

 

posted @ 2020-06-02 23:18  风止雨歇  阅读(6371)  评论(0编辑  收藏  举报