(五)Java并发编程之Synchronized

2019-11-06  本文已影响0人  陪安东尼的漫长岁月

线程同步器的意义

多线程编程中,有可能会出现多个线程同时访问同一个共享、可变资源的情况,这个资源我们称之其为临界资源;这种资源可能是:对象、变量、文件等。由于线程执行的过程是不可控的,所以需要采用同步机制来协同对对象可变状态的访问。

Q:怎么解决线程并发访问的问题?
A:序列化访问临界资源。即在同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。
Java中,提供了两种方式来实现同步互斥访问 synchronizedLock

Synchronized详解

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

加锁的方式:
  1. 同步实例方法,锁是当前实例对象
  2. 同步类方法(静态方法),锁是当前类对象
  3. 同步代码块,锁是括号里面的对象
底层原理:

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

  1. synchronized关键字被编译成字节码后会被翻译成monitorenter 和monitorexit 两条指令分别在同步块逻辑代码的起始位置与结束位置。
synchronized翻译后指令.png
  1. 每个同步对象都有一个自己的Monitor(监视器锁),锁状态被记录在每个对象的对象头(Mark Word)中。加锁过程如下图所示:
加锁过程.png
对象的内存布局

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

对象内存布局.png
对象头

HotSpot虚拟机的对象头包括两部分信息,第一部分是“Mark Word”,用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,这部分数据的长度在32位和64位的虚拟机(暂 不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额 外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,MarkWord的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。

32位虚拟机对象头部信息.png

但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。
对象头信息是与对象自身定义的数据无关的额外存储成本,但是考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据,它会根据对象的状态复用自己的存储空间,也就是说,Mark Word会随着程序的运行发生变化,变化状态如下(32位虚拟机):

对象头变化情况.png
内置锁的膨胀升级过程

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

锁粗化
    StringBuffer stb = new StringBuffer();
    public void test1(){
        //jvm的优化,锁的粗化
        stb.append("1");
        stb.append("2");
        stb.append("3");
        stb.append("4");
    }
    /**
     *     @Override
     *     @HotSpotIntrinsicCandidate
     *     public synchronized StringBuffer append(String str) {
     *         toStringCache = null;
     *         super.append(str);
     *         return this;
     *     }
     */
锁消除
    public void test2(){
        // jvm的优化,JVM不会对同步块进行加锁
        synchronized (new Object()) {
            // 很多逻辑
            // jvm是否会加锁?
        }
    }
逃逸分析
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 Person alloc() {
        // Jit对编译时会对代码进行 逃逸分析
        // 并不是所有对象存放在堆区,有的一部分存在线程栈空间
        Person person = new Person();
        return person;
    }

    static class Person {
        private String name;
        private int age;
    }
}
开启逃逸分析执行结果.png

这时会被JIT优化所以并没有初始化够500000个对象。


关闭逃逸分析执行结果.png

关闭后不会发生线程逃逸,所以老老实实初始化完500000个对象。

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  2. 将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  3. 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中

参考:https://www.hollischuang.com/archives/2583

上一篇 下一篇

猜你喜欢

热点阅读