Synchronized总结&锁优化

待确认

  1. 轻量级锁相关
    1. 线程启动时,在栈空间初始化锁状态,对象头升级为轻量级锁时,就使用CAS修改对象头的锁状态为栈空间初始化的那个
    2. 怎么重入?1:03 非轻量级锁怎么重入?

1. Synchronized(一)基础总结

  1. 应用场景
    解决多线程安全问题
  2. 方式
    加锁:序列化访问临界资源
  3. Java中的实现
    1. 阻塞式:synchronized、reentrantlock
    2. 非阻塞式:CAS+自旋
  1. 使用
    无论哪种方式,锁住的都是Java对象。
    1. 方法上:acc_synchronized,锁住实例对象/类对象
    2. 代码块中:monitorenter、monitorexit,锁住实例对象
  1. 原理
    1. JVM指令:方法上acc_synchronized;代码块中monitorenter、monitorexit
    2. Monitor/管程:Java锁体系的设计思想,设计的理论基础
      1. MESA模型:入口等待队列(互斥)、多个条件队列(同步、阻塞唤醒机制)
      2. synchronized——简化版MESA模型:Object.wait/notify/notifyAll都依赖于monitor对象。只有重量级锁状态才有Monitor对象。
    1. 重量级锁优化:偏向锁、轻量级锁,都只是更新维护markword头信息中的锁状态,不会创建monitor对象和进行os mutex线程上下文切换
    2. 偏向锁
      不存在竞争,偏向某个线程。 同一个线程进入偏向锁状态的临界区,没有加锁、解锁开销。
    3. 轻量级锁
      线程间存在轻微竞争,比如交替执行,适应于临界区逻辑简单,执行速度快。  
      CAS方式获取锁,即修改markword锁状态信息,将其与线程栈中的lockrecord做双向映射,支持锁重入。
      CAS失败,即进入膨胀为重量级锁的过程。
    4. 重量级锁
      线程间竞争激烈。
      膨胀期间,JVM创建一个Monitor对象,负责维护多线程之间的阻塞唤醒队列,是非公平锁。
      为了避免OS直接挂起、唤醒线程带来的上下文切换的开销,在线程获取不到锁之后,会进行自适应的自旋,最后自旋一定时间后,进入挂起状态。
    5. 加锁/解锁标记
      不同锁在对象头中的标记状态
    6. 实验
      1. synchronized加锁在对象上,对象头的锁状态变化。
      2. 锁升级、锁撤销
    1. 对象内存布局
      JOL测试
  1. 误区
    这部分会在第二部分详细说明。
    1. 关于三种锁状态之间的转换关系
      1. 误区一:锁状态变化是无锁——>偏向锁——>轻量级锁——>重量级
        错误:不存在无锁——>偏向锁,锁状态之间的转换不是按字面顺序的。
      2. 误区二:轻量级锁自旋获取锁失败,会膨胀升级为重量级锁 
        错误:轻量级锁不会自旋,CAS修改markword对象头失败后,就会进入重量级锁膨胀阶段。
      3. 误区三:重量级锁不存在自旋
        错误:重量级锁存在自旋,目的是为了避免直接挂起线程导致的OS线程上下文切换开销。

2. Synchronized锁优化

2.1 偏向锁批量重偏向&批量撤销

从偏向锁的加锁解锁过程中可看出,当只有一个线程反复进入同步块时,偏向锁带来的性能开销基本可以忽略,但是当有其他线程尝试获得锁时,就需要等到safe point时,再将偏向锁撤销为无锁状态或升级为轻量级,会消耗一定的性能,所以在多线程竞争频繁的情况下,偏向锁不仅不能提高性能,还会导致性能下降。于是,就有了批量重偏向与批量撤销的机制。

有用的博客

盘一盘 synchronized (二)—— 偏向锁批量重偏向与批量撤销 - 柠檬五个半 - 博客园

2.1.1 原理

以class为单位,为每个class维护一个偏向锁撤销计数器,每一次该class的对象发生偏向撤销操作时,该计数器+1,当这个值达到重偏向阈值(默认20)时,JVM就认为该class的偏向锁有问题,因此会进行批量重偏向。

当达到重偏向阈值(默认20)后,假设该class计数器继续增长,当其达到批量撤销的阈值后(默认40),JVM就认为该class的使用场景存在多线程竞争,会标记该class为不可偏向,之后,对于该class的锁,直接走轻量级锁的逻辑。

2.1.2 应用场景

  • 批量重偏向(bulk rebias)
    一个线程创建了大量对象并执行了初始的同步操作,后来另一个线程也来将这些对象作为锁对象进行操作,这样会导致大量的偏向锁撤销操作。
  • 批量撤销(bulk revoke)
    在明显多线程竞争剧烈的场景下使用偏向锁是不合适的。

2.1.3 JVM参数

  • -XX:+PrintFlagsFinal
    在项目启动时可输出JVM的默认参数值
  • BiasedLockingBulkRebiasThreshold=20
    偏向锁批量重偏向默认阈值

2.1.4 测试

批量重偏向

/**
 * 偏向锁批量重偏向  -XX:BiasedLockingStartupDelay=4000
 */
@Slf4j
public class BiasedLockingTest {
    public static void main(String[] args) throws InterruptedException {
        //延时产生可偏向对象
        Thread.sleep(5000);
        // 创建一个list,来存放锁对象
        List<Object> list = new ArrayList<>();

        // 线程1
        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                // 新建锁对象
                Object lock = new Object();
                synchronized (lock) {
                    list.add(lock);
                }
            }
            try {
                //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead1").start();

        //睡眠3s钟保证线程thead1创建对象完成
        Thread.sleep(3000);
        log.debug("打印thead1,list中第20个对象的对象头:");
        log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));

        // 线程2
        new Thread(() -> {
            for (int i = 0; i < 30; i++) {
                Object obj = list.get(i);
                synchronized (obj) {
                    if (i >= 15 && i <= 25) {
                        log.debug("thread2-第" + (i + 1) + "次加锁执行中\t" +
                                ClassLayout.parseInstance(obj).toPrintable());
                    }
                }
                if (i == 17 || i == 19) {
                    log.debug("thread2-第" + (i + 1) + "次释放锁\t" +
                            ClassLayout.parseInstance(obj).toPrintable());
                }
            }
            log.debug("thread2-第" + (1) + "个对象\t" +
                    ClassLayout.parseInstance(list.get(0)).toPrintable());
            log.debug("thread2-第" + (31) + "个对象\t" +
                    ClassLayout.parseInstance(list.get(30)).toPrintable());
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead2").start();

        //睡眠3s钟保证线程thead2偏向撤销完成
        Thread.sleep(3000);



        // 线程3
        new Thread(() -> {
            Object obj = list.get(21);
            synchronized (obj) {
                log.debug("thread3-执行中\t" +
                        ClassLayout.parseInstance(obj).toPrintable());
            }
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead3").start();


        LockSupport.park();
    }
}

结果

  1. Object类对象的第20个对象头开始,轻量级锁状态变成重偏向锁,对象头中线程1的ID偏向为线程2的ID;前面的19个对象头,是轻量级锁,符合常规情况
  2. 线程3对第22个对象头进行重偏向测试,发现对象头偏向锁并没有再次重偏向线程3,而是直接变成了轻量级锁,证明对象头只能重偏向一次
  3. 第31个对象头偏向锁ID仍然偏向线程1,因此批量重偏向是懒加载。

批量撤销

@Slf4j
public class BiasedLockingTest2 {
    public static void main(String[] args) throws InterruptedException {
        //延时产生可偏向对象
        Thread.sleep(5000);
        // 创建一个list,来存放锁对象
        List<Object> list = new ArrayList<>();

        // 线程1
        new Thread(() -> {
            for (int i = 0; i < 50; i++) {
                // 新建锁对象
                Object lock = new Object();
                synchronized (lock) {
                    list.add(lock);
                }
            }
            try {
                //为了防止JVM线程复用,在创建完对象后,保持线程thead1状态为存活
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead1").start();

        //睡眠3s钟保证线程thead1创建对象完成
        Thread.sleep(3000);
        log.debug("打印thead1,list中第20个对象的对象头:");
        log.debug((ClassLayout.parseInstance(list.get(19)).toPrintable()));

        // 线程2
        new Thread(() -> {
            for (int i = 0; i < 40; i++) {
                Object obj = list.get(i);
                synchronized (obj) {
                    if (i >= 15 && i <= 21 || i >= 38) {
                        log.debug("thread2-第" + (i + 1) + "次加锁执行中\t" +
                                ClassLayout.parseInstance(obj).toPrintable());
                    }
                }
                if (i == 17 || i == 19) {
                    log.debug("thread2-第" + (i + 1) + "次释放锁\t" +
                            ClassLayout.parseInstance(obj).toPrintable());
                }
            }
            try {
                Thread.sleep(100000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thead2").start();


        Thread.sleep(3000);

        new Thread(() -> {
            for (int i = 20; i < 50; i++) {
                Object lock = list.get(i);
                if (i <= 21 || i >= 35 && i <= 41) {
                    log.debug("thread3-第" + (i + 1) + "次准备加锁\t" +
                            ClassLayout.parseInstance(lock).toPrintable());
                }
                synchronized (lock) {
                    if (i <= 21 || i >= 35 && i <= 41) {
                        log.debug("thread3-第" + (i + 1) + "次加锁执行中\t" +
                                ClassLayout.parseInstance(lock).toPrintable());
                    }
                }
            }
        }, "thread3").start();

        Thread.sleep(3000);
        log.debug("查看新创建的对象");
        log.debug((ClassLayout.parseInstance(new Object()).toPrintable()));

        LockSupport.park();
    }
}

结果

  1. 线程3执行时,导致20-50线程的偏向锁升级为轻量级锁。
  2. 新创建的Object对象的对象头本应该是偏向锁状态,但实际是无锁状态。

2.1.5 总结

  1. 批量重偏向和批量撤销是针对类的优化,和对象无关。
  2. 偏向锁重偏向一次之后不可再次重偏向。
  3. 重偏向是懒加载机制。
  4. 当某个类已经触发批量撤销机制后,JVM会默认当前类产生了严重的问题,剥夺了该类的新实例对象使用偏向锁的权利

2.2 重量级锁自旋

重量级锁竞争的时候,还可以使用自旋来进行优化,如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。 
  • 在 Java 6 之后自旋是自适应的,比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;反之,就少自旋甚至不自旋,比较智能。 
  • Java 7 之后不能控制是否开启自旋功能

自旋的目的是为了减少线程挂起的次数,尽量避免直接挂起线程(挂起操作涉及系统调用,存在用户态和内核态切换,这才是重量级锁最大的开销)

2.3 锁粗化

2.3.1 概述

假设一系列的连续操作都会对同一个对象反复加锁及解锁,甚至加锁操作是出现在循环体中的,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。

2.3.2 测试

public class LockCoarseningTest {
    public static void main(String[] args) {
        // 情况1
        StringBuffer buffer = new StringBuffer();
        buffer.append("21321").append("23121");

        // 情况2
        Object lock = new Object();
        synchronized (lock) {
            //do something
        }
        synchronized (lock) {
            //do something
        }
    }
}

上述代码每次调用 buffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append方法时进行加锁,最后一次append方法结束后进行解锁。

2.4 锁消除

2.4.1 概述

锁消除即删除不必要的加锁操作。锁消除是Java虚拟机在JIT编译期间,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

2.4.2 测试

/**
 * 锁消除
 * -XX:+EliminateLocks 开启锁消除(jdk8默认开启)
 * -XX:-EliminateLocks 关闭锁消除
 */
public class LockEliminationTest {
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    public static void main(String[] args) throws InterruptedException {
        LockEliminationTest demo = new LockEliminationTest();
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            demo.append("aaa", "bbb");
        }
        long end = System.currentTimeMillis();
        System.out.println("执行时间:" + (end - start) + " ms");
    }
}

StringBuffer的append是个同步方法,但是append方法中的 StringBuffer 属于一个局部变量,不可能从该方法中逃逸出去,因此其实这过程是线程安全的,可以将锁消除。

测试结果: 关闭锁消除执行时间1813ms 开启锁消除执行时间:1095 ms

2.5 逃逸分析

2.5.1 概述

逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域。

方法逃逸

当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。

线程逃逸

这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量。

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

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

jdk6才开始引入该技术,jdk7开始默认开启逃逸分析。在Java代码运行时,可以通过JVM参数指定是否开启逃逸分析:

-XX:+DoEscapeAnalysis //表示开启逃逸分析 (jdk1.8默认开启) 

-XX:-DoEscapeAnalysis //表示关闭逃逸分析

-XX:+EliminateAllocations //开启标量替换(默认打开) 

2.5.2 测试

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

    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 500000; i++) {
            alloc();
        }

        long end = System.currentTimeMillis();

        log.info("执行时间:" + (end - start) + " ms");
        try {
            Thread.sleep(Integer.MAX_VALUE);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    /**
     * JIT编译时会对代码进行逃逸分析
     * 并不是所有对象存放在堆区,有的一部分存在线程栈空间
     * Ponit没有逃逸
     */
    private static String alloc() {
        Point point = new Point();
        return point.toString();
    }

    /**
     * 同步省略(锁消除)  JIT编译阶段优化,JIT经过逃逸分析之后发现无线程安全问题,就会做锁消除
     */
    public void append(String str1, String str2) {
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(str1).append(str2);
    }

    /**
     * 标量替换
     */
    private static void test2() {
        Point point = new Point(1, 2);
        System.out.println("point.x=" + point.getX() + "; point.y=" + point.getY());

//        int x=1;
//        int y=2;
//        System.out.println("point.x="+x+"; point.y="+y);
    }
}

@Data
@AllArgsConstructor
@NoArgsConstructor
class Point {
    private int x;
    private int y;
}

结果

  1. 关闭逃逸分析

  2. 开启逃逸分析

2.6 总结

2.6.1 三种锁之间的状态转换

  1. 对象锁初始化状态:匿名偏向
  2. 如果对象被一个线程作为锁对象,变为偏向锁状态。
  3. 如果对象锁被2个线程交替使用
    线程1使用锁时为偏向锁状态;线程2使用锁时为轻量级锁状态
  4. 如果对象锁被激烈竞争
    线程1和线程2都是重量级锁

2.6.2 轻量级锁源码流程

2.6.3 重量级锁加锁解锁源码流程

2.6.4 锁重入

  1. 偏向锁
    根据偏向线程ID判断
  2. 轻量级锁
    1. lockrecord
  1. 重量级锁
    1. monitor

猜你喜欢

转载自blog.csdn.net/peterjava123/article/details/128690723
今日推荐