美文网首页面试精选java
从青铜到王者的路线,来聊聊Synchronized底层实现原理

从青铜到王者的路线,来聊聊Synchronized底层实现原理

作者: 废柴程序员 | 来源:发表于2021-09-07 22:28 被阅读0次

    一、引言

    文本内容有点多,如果有写错或者不好地方,还请多多指教~~~~~~~

    二、倔强青铜

    2.1 多线程一定快吗?

    我们先来看下面一段代码,有两个方法对各自a、b属性进行累加操作,其中concurrency方法是采用多线程进行操作,结果如下:

    
    public class ConcurrencyTest {
     
        // 累加次数
        private static final long count = 10000L;
     
        public static void main(String[] args) throws InterruptedException {
            concurrency();
            serial();
        }
     
        /**
         * 多线程累加
         *
         * @throws InterruptedException
         */
        private static void concurrency() throws InterruptedException {
            long start = System.currentTimeMillis();
     
            // 启动新线程执行运行操作
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    int a = 0;
                    for (int i = 0; i < count; i++) {
                        a += 5;
                    }
                }
            });
            thread.start();
     
            int b = 0;
            for (int i = 0; i < count; i++) {
                b--;
            }
            // 等线程执行完
            thread.join();
            long end = System.currentTimeMillis() - start;
            System.out.println("concurrency 总共耗时" + end);
        }
     
        /**
         * 单线程累加
         */
        private static void serial() {
            long start = System.currentTimeMillis();
            int a = 0;
            for (int i = 0; i < count; i++) {
                a += 5;
            }
            int b = 0;
            for (int i = 0; i < count; i++) {
                b--;
            }
            long end = System.currentTimeMillis() - start;
            System.out.println("serial 总共耗时" + end);
        }
    }
    
    

    那这边的答案是"不一定"的,小编测试了几组数据如下(抽取部分结果):

    多线程与单线程效率测试

    循环次数 单线程执行 多线程执行 效率
    1万 0 1
    1万 0 0 相等
    十万 2 2 相等
    十万 1 1 相等

    由以上的结果可以明确我们的答案是正确的,那为什么多线程在某些情况下会比单线程还要慢呢? 这是因为多线程有创建和上下文切换的开销。

    2.2 上下文切换

    那什么是上下文切换呢?

    目前来说即使是单核处理器也支持多线程执行代码,CPU通过给个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片一般是几十毫秒,所以CPU需要通过不停地切换线程来执行。 假设当我们线程A获得CPU分配的时间片等于10毫秒,执行10毫秒之后,CPU需要切换到线程B去执行程序。等线程B的时间片执行完事了,又切回线程A继续执行。

    显然易见,我们CPU相当于是循环的切换上下文,来达到同时执行的效果。当前执行完一个时间片后会切换下一个任务。但是在切换前会保存当前任务的状态,方便下次切换会这个任务的时候,可以恢复这个任务之前的状态。 所以任务从保存到再次被加载的过程就是一次上下文切换。

    2.3 测试上下文切换次数

    这里我们需要使用一个命令叫做:"vmstat 1",这个命令是linux系统上的,可对操作系统的进程、虚拟内存、CPU活动进行监控。看下图CS(Content Switch) 表示上下文切换的次数,从图可见系统一般CS的值维持在600~800之间,当我们一直在运行ConcurrencyTest程序时,很明细发现CS飙升到1000以上。

    image

    2.4 Java内存模型

    在我们学习sync原理之前,我们需要搞清楚Java内存模型的一个概念知识。很重要、很重要、很重要

    image

    Java内存模型全称:Java Memory Model ,简称Java内存模型或者JMM,Java线程之间的通信由JMM来控制,JMM决定一个线程对共享变量的写入,何时对另外一个线程可见。我们由图可见,线程之间的共享变量是存储在主内存当中,每一个线程都有一个属于自己的本地内存(也可以叫做工作内存),这个本地内存中存储了主内存当中的共享变量。就相当于把主内存的共享变量copy了一份给自己。为了提供效率,线程是不会直接与主内存进行打交道,而是通过本地内存来进行数据的读取。

    如果线程A与线程B之间要通信,需要经历下面两个步骤:

    1 )线程A把本地内存A中更新过的共享变量,刷新到主内存当中去。

    2 )线程B到主内存中重新读取更新后的共享变量。

    2.5 主内存与工作内存之间的数据交互过程

    那么主内存与工作内存之间的交互经过了哪些步骤呢?

    image

    lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

    unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放之后的变量才可以被其他线程锁定。

    read(读取):作用于主内存的变量,读取主内存变量的值。

    load(载入):作用于主内存的变量,把read操作从主内存中得到的变量值放入到线程本地内存的变量副本中。

    use(使用):作用于工作内存的变量,把工作内存中的一个变量传递给执行引擎。

    assign(赋值):作用域工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量。

    store(存储):作用域工作内存的变量,把工作内存中的一个变量值传输到主内存中,以便随后的write操作。

    write(写入):作用域工作内存的变量,把stroe操作从工作内存中一个变量的值传送到主内存的变量中去。

    上个笔记图: 更加详细的解释如上几个步骤

    image

    JMM是一种规范,其中定义几条规则,小编挑选出相对本文比较重要的:

    1、如果想要把一个变量从主内存复制到工作内存,就需要按照顺序执行read和load操作,如果把变量从工作内存同步到主内存中,就要按照顺序执行store和write操作。但Java内存模型只要求上述操作必须按照顺序执行,而没有保证必须是连续执行。

    2、程序中如果有同步操作才会有lock和unlock操作,一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,执行多次后,必须执行相对应次数但unlock操作,变量才会被解锁。lock和unlock必须成对出现。

    3、如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或者assign操作初始化变量但值。

    4、java内存模型同步规则小编暂时提到这么多,感兴趣的小伙伴可以自行去了解一下

    三、秩序白银

    3.1 多线程带来的可见性问题

    什么是可见性问题呢?

    所谓可见性:一个线程对主内存的修改可以及时被其他线程观察到。

    当一个共享属性,被线程二修改了,但是线程一无法获得最新的值,导致死循环。原因Java内存模型也说清楚了,线程是和本地内存做交互的。

    1、线程一把falg属性读取到线程私有的本地内存中,值为true。

    2、线程二把falg属性修改为false,并且刷新到主内存当中,但是线程一它是不知道falg被修改了。

    public class SyncExample5 {
     
        static boolean falg = true;
     
        // 锁对象
        static Object lock = new Object();
     
        public static void main(String[] args) throws InterruptedException {
     
            // 线程一
            new Thread(new Runnable() {
                @Override
                public void run() {
                    while (falg) {
                        // 默认不可见,死循环,放开以下注释即可解决不可见操作
                        
                        // 方式一,加上sycn操作即可解决可见性问题
                        // synchronized (lock){}
     
                        // 方式二, println 方法实现加上了同步机制,保证每次输出都是最新值
                        // System.out.println(falg);
                    }
                }
            }).start();
     
            // 睡眠两秒
            Thread.sleep(2000L);
     
            // 线程二
            new Thread(new Runnable() {
                @Override
                public void run() {
                    falg = false;
                    System.out.println("falg 值已修改");
                }
            }).start();
        }
    }
    
    

    sync怎么解决可见性问题呢?

    这个就涉及到本地内存与工作内存交互的步骤了,还记得文本上面有讲的8个步骤吗?

    如果程序中有加同步的机制,则会有Lock、Unlock操作,Lock操作会使本地内存中的属性失效,从而去主内存中重新读取数据。

    image

    3.2 多线程带来的原子性问题

    什么是原子性问题呢?

    所谓原子性:提供了互斥访问,同一个时刻只能有一个线程来对它进行操作。

    这里一次任务累加1千次,同时启动5个线程进行累加,最后的结果正常应该是5000才对,但由于多线程会造成不一样的结果。

    public class SyncExample6 {
     
        static int index = 0;
     
        static Object lock = new Object();
     
        public static void main(String[] args) throws InterruptedException {
     
            // index 累加 1000次,使用lambda表达式
            Runnable task = () -> {
                // 不加sync则不能保证原子操作
                // synchronized (lock) {
                    for (int i = 0; i < 1000; i++) {
                        index++;
                    }
                // }
            };
     
            // 启动五个线程来执行任务
            for (int i = 0; i < 5; i++) {
                Thread thread = new Thread(task);
                thread.start();
            }
     
            // 为了代码直观直接睡眠等待结果,实际需要调用线程的join方法等待线程结束
            Thread.sleep(2000L);
            System.out.println("index = " + index);
        }
    }
    
    

    我们使用java命令来编译以上代码:

    javac SyncExample6.java

    javap -p -v SyncExample6.class ,这样我们就能看到sync到底在底层做了什么事。

    编译代码之后找到“lambdamain0”,因为我们同步机制是写在main方法中,用lambda表达式所写。

     private static void lambda$main$0();
        descriptor: ()V
        flags: ACC_PRIVATE, ACC_STATIC, ACC_SYNTHETIC
        Code:
          stack=2, locals=3, args_size=0
             0: iconst_0
             1: istore_0
             2: iload_0
             3: sipush        1000
             6: if_icmpge     39
             9: getstatic     #18                 // Field lock:Ljava/lang/Object;
            12: dup
            13: astore_1
            14: monitorenter
            15: getstatic     #14                 // Field index:I
            18: iconst_1
            19: iadd
            20: putstatic     #14                 // Field index:I
            23: aload_1
            24: monitorexit
            25: goto          33
            28: astore_2
    
    

    造成原子性的问题的原因是什么?

    这个就涉及到文章一开始所讲的上下文切换的知识点,index ++ 一共涉及到4条指令,如下

    15: getstatic     #14  // 步骤一:获取index值
    18: iconst_1           // 步骤二:准备常量1
    19: iadd               // 步骤三:相加操作
    20: putstatic     #14  // 步骤四:重新赋值
    

    以上这4条指令就是index ++ 的四个步骤,假设我们线程一进来,执行到步骤三,这个时候CPU切换线程。切换到线程二,线程二执行步骤一,这个时候index的值还是等于0,因为线程一并没有执行步骤四就被切换上下文了。 等线程二执行完成,又切回到线程一,线程一会接着执行步骤三,并不会重新获取index的值,这就导致计算结果不正确了。

    sync怎么解决原子性问题呢?

      14: monitorenter
      15: getstatic     #14                 // Field index:I
      18: iconst_1
      19: iadd
      20: putstatic     #14                 // Field index:I
      23: aload_1
      24: monitorexit
    

    当我们加上了sync同步机制之后, 会插入monitorenter、monitorexit两条指令。

    又到了假设环节:假设线程一执行到步骤三,被切换到线程二,当我们线程二执行monitorenter这个指令会发现,这个对象已经被其他线程占用了,所以就只能等待着不会进行操作。现在又切回到线程一,线程一操作完整个步骤执行monitorexit来释放锁。这个时候线程二才可以获得锁。 这样一操作就能保证同一个时刻只能有一个线程来对它进行操作,从而保证原子性。

    monitorenter指令是在编译后插入到同步代码块到开始位置,而monitorexit是插入到同步代码块结束位置和异常位置。JVM需要保障每个monitorenter必须有对应的monitorexit。任何一个对象都会有一个monitor来关联,当且一个monitor被持有后,它就处理锁定状态。当线程执行到monitorenter指令的时候,将会尝试获取对象所对应的monitor的所有权,即尝试获取锁对象。

    3.3 多线程带来的有序性问题

    什么是有序性问题呢?

    有序性,指的是程序中代码的执行顺序,Java在编译时和运行时会对代码进行优化,会导致程序最终的执行顺序不一定就是我们编写代码时的顺序。

    // 指定使用并发测试
    @JCStressTest 
    // 预测的结果与类型,附加描述信息,如果1,4 则是ok,如果结果有为0也能勉强接受
    @Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok") 
    @Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
    // 标注需要测试的类
    @State
    public class TestJMM {
     
        int num = 0;
        boolean ready = false;
     
        @Actor
        public void actor1(I_Result r) {
            if (ready) {
                r.r1 = num + num;
            } else {
                r.r1 = 1;
            }
        }
     
        @Actor
        public void actor2(I_Result r) {
            num = 2;
            ready = true;
        }
    }
    
    

    是时候贴一段代码凑文章字数了,这里代码用了Jcstress高并发测试框架,目的是为了能够演示有序性所导致到问题。

    小伙伴可以先仔细看以上代码,假设actor1、actor2 各有一个线程进来,想想 r.r1 的值会产生几种情况。

    小编告诉你吧,其实答案有三种,分别是:1、4、0

    出现1的情况:

    1)假设 actor1先获得执行权,ready = false ,则 r.r1 = 1;

    2)假设 actor2先获得执行权,执行到num = 2, 线程切换到actor1,ready还是为false,r.r1 = 1;

    出现4的情况:

    1)假设actor2先获得执行权,执行完,此时ready = true,num = 2 ,等到在执行actor1时,结果为4;

    出现0的情况:

    1)这里就是重点了,假设actor2获得执行权,由于指令重排序导致actor2代码顺序更换。

    这个时候执行到ready = true,线程切换到actor1,这个时候ready已经等于true了,但是num还是0,所以就出现了0的情况。

          @Actor
        public void actor2(I_Result r) {
            // 由于指令重排序,导致下面代码更换了顺序,如下:
            ready = true;
            num = 2;
        }
    
    

    我们用压测来执行以下代码吧,使用maven 执行 clean install,会生成一个jar包,直接用命令启动jar包就行了,Jcstress使用方式小编就不多说了,感兴趣的小伙伴可以自行学习下, 执行的结果也符合我们预期的值。

    image

    sync怎么解决有序性问题呢?

    这个时候只需要在actor1和actor2分别加上锁操作,由于它们的锁对象都是同一个,哪怕由于指令重排序执行到actor2的ready = true,这个时候线程切换到actor1,但是有加锁所以actor1也只能等着。 等到actor2 把 num = 2 执行完,actor1 才可以拿到锁对象。

    // 指定使用并发测试
    @JCStressTest
    // 预测的结果与类型,附加描述信息
    @Outcome(id = {"1"}, expect = Expect.ACCEPTABLE, desc = "ok")
    // 因为sync解决有序性问题,不会有0的出现,为了方便观察结果,我们把4设置成能勉强接受的值
    @Outcome(id = {"4"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
    @Outcome(id = {"0"}, expect = Expect.ACCEPTABLE_INTERESTING, desc = "denger")
    // 标注需要测试的类
    @State
    public class TestJMM {
     
        int num = 0;
        boolean ready = false;
     
        Object lock = new Object();
     
        @Actor
        public void actor1(I_Result r) {
            synchronized (lock) {
                if (ready) {
                    r.r1 = num + num;
                } else {
                    r.r1 = 1;
                }
            }
        }
     
        @Actor
        public void actor2(I_Result r) {
            synchronized (lock) {
                num = 2;
                ready = true;
            }
        }
    }
    
    

    测试结果如下:

    image

    四、荣耀黄金

    4.1 sync可重入特性

    什么是可重入呢?

    即一个线程可以多次执行synchronzied重复获取同一把锁。 sync底层锁对象中包含了一个计数器(recursions 变量),会记录线程获得了几次锁。 当我们同一个线程获得了锁,计数器则会+1,执行完同步代码块,计数器-1。 直到计数器的数量为0,就释放这个锁对象。

    public class SyncExample8 {
     
        public static void main(String[] args) {
            new MyThread().start();
        }
     
    }
     
    class MyThread extends Thread {
        @Override
        public void run() {
            synchronized (MyThread.class) {
                System.out.println(getName() + "进入了同步代码块1");
                synchronized (MyThread.class) {
                    System.out.println(getName() + "进入了同步代码块2");
                }
            }
        }
    }
    
    

    运行结果如下,我们可以很明细的看出在输出“同步代码块1”之后,不需要等待锁释放,即可进入第二个同步代码块。这样的一个特性可以避免死锁的发生,也可以更好的封装代码(即:同步代码块中的代码,可以分成多个方法来写)。

    输入结果如下:

    Thread-0进入了同步代码块1
    Thread-0进入了同步代码块2

    4.2 sync不可中断特性

    不可中断只指,线程二在等待线程一释放锁的时候,是不可被中断的。

    当一个线程获得锁之后,另外一个线程一直处于堵塞或者等待状态,前一个线程不释放锁,后一个线程会一直被阻塞或等待,所以sync是不可中断锁。

    public class SyncExample9 {
     
        private static Object lock = new Object();
     
        public static void main(String[] args) throws InterruptedException {
            Runnable run = () -> {
                synchronized (lock) {
                    String name = Thread.currentThread().getName();
                    System.out.println(name + "进入同步代码块");
                    try {
                        // 让线程一持有锁
                        Thread.sleep(888888L);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            };
     
            // 创建线程一先执行同步代码快
            Thread t1 = new Thread(run);
            t1.start();
     
            // 主线程睡眠一下,保证上面线程先执行
            Thread.sleep(1000L);
     
            // 后开启线程取执
            Thread t2 = new Thread(run);
            t2.start();
     
            System.out.println("开始中断线程二");
            // 强行线程二中断
            t2.interrupt();
            System.out.println("线程一状态" + t1.getState());
            System.out.println("线程二状态" + t2.getState());
     
        }
     
    }
    
    

    当我们线程一进入同步代码之后,一直持有锁,并且睡眠了(也证实了sleep方法睡眠不会释放锁对象)。

    此时线程二启动去尝试获取锁,获取失败之后就变成堵塞状态,哪怕我们强行中断线程二,最后看到线程二的状态仍是堵塞的。

    Thread-0进入同步代码块
    开始中断线程二
    线程一状态TIMED_WAITING
    线程二状态BLOCKED

    4.3 反汇编学习sync原理

    使用javap反汇编java代码,引入monitor概念。

    public class SyncExample10 {
     
        private static Object lock = new Object();
     
        public static void main(String[] args) throws InterruptedException {
            synchronized (lock) {
                System.out.println("1");
            }
        }
     
        public synchronized void test() {
            System.out.println("1");
        }
     
    }
    
    

    我们使用javac、javap 两个命令对SyncExample10来进行编译

    javac SyncExample10.java

    javap -v -p SyncExample10.class

    编译后的指令就如下啦,我们主要看main方法里面的内容,着重看 monitorenter、monitorexit 两个指令

     public static void main(java.lang.String[]) throws java.lang.InterruptedException;
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
          stack=2, locals=3, args_size=1
             0: getstatic     #2                 
             3: dup
             4: astore_1
             5: monitorenter    // 这里
             6: getstatic     #3                 
             9: ldc           #4                
            11: invokevirtual #5                  
            14: aload_1
            15: monitorexit  // 这里
            16: goto          24
            19: astore_2
            20: aload_1
            21: monitorexit  // 这里
            22: aload_2
            23: athrow
            24: return
    
    

    monitorenter 指令

    当我们进入同步代码块的时候会先执行monitorenter指令,每一个对象都会和一个monitor监视器关联,监视器被占用时会被锁住,其他线程无法来获取该monitor。当其他线程执行monitorente指令时,它会尝试去获取当前对象对应的monitor的所有权。

    monitor里面有两个很重要成员变量:

    owner: 当一个线程获取到该对象的锁,就把线程当前赋值给owner。

    recursions:会记录线程拥有锁的次数,重复获取锁当前变量也会+1,当一个线程拥有monitor后,其他线程只能等待。

    monitorenter执行流程如下:

    1)若monitor的进入次数为0时,线程可以进入monitor,并将monitor进入的次数(recursions)+1,当前线程成为montiro的owner(所有者);

    2)若线程已拥有monitor的所有权,允许它重入monitor,进入一次次数+1 (可重复特性);

    3)若其他线程已经占有monitor,那么当前尝试获取monitor的线程会被阻塞,一直到monitor进入次数为变0,才能重新被再次获取。

    monitorexit 指令

    既然我们同步代码块进入时计数器会执行+1操作,那么我们退出的时候,计数器当然要执行-1;

    要注意,能够执行monitorexit指令的线程,一定是拥有当前对象的monitor所有权的线程。 当我们执行monitorexit指令计数器减到为0时,当前线程就不再拥有monitor所有权。其他被阻塞的线程即可再一次去尝试获取这个monitor的所有权。

    大家仔细看看上面编译出来的指令,其实monitoreexit是有两个的,为什么呢?

    因为需要保证如果同步代码块执行抛出了异常,则也需要释放锁对象。等到下次面试官问你,synchronized如果抛异常了,会不会释放锁对象,答案是:会的。

    ACC_SYNCHRONIZED 修饰

    刚刚我们所看到的是mian方法中同步代码块所编译后的指令,以下是同步方法编译后指令

    可以看到同步方法在反汇编后,会增加ACC_SYNCHRONIZED修饰,会隐式调用monitorenter、mointorexit,在执行同步方法前会调用monitorenter,在方法结束之后会调用monitorexit。

     public synchronized void test();
        descriptor: ()V
        flags: ACC_PUBLIC, ACC_SYNCHRONIZED
        Code:
          stack=2, locals=1, args_size=1
             0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
             3: ldc           #4                  // String 1
             5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
             8: return
          LineNumberTable:
            line 19: 0
            line 20: 8
    
    

    五、尊贵铂金

    5.1 montior 监视器锁

    刚刚上文有提到每一个对象都会和一个monitor监视器关联,真正的锁都是靠monitor监视器来完成,

    那monitor到底是个啥玩意呢? 小编偷偷告诉你,其实monitor是用C++所写。

    http://hg.openjdk.java.net/jdk8/jdk8/hotspot/ 网址都给你们找好了,点击左边zip、gz下载都行。 网速不好的同学可以在网上“hotspot 源码下载” ,下载之后文件如下图:

    image

    下载之后为了方便浏览,小编建议你们可以去下载一个CLion工具来看代码,或者直接用文本编辑器打开也行。

    java对象怎么和monitor关联的呢?

    这里就牵扯到另外一个知识点,我们每一个对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。而这个对象头就包含了一个monitor的引用地址,指向了一个具体的monitor对象。

    image

    monitor里面包含了什么?

    我们先找到monitor对象对应的源文件:/src/share/vm/runtime/objectMonitor.hpp,往下翻可以看到ObjectMonitor的构造方法,里面有一系列成员属性。

    ObjectMonitor() {
        _header       = NULL;
        _count        = 0;
        _waiters      = 0,
        _recursions   = 0;        // 记录线程的重入次数
        _object       = NULL;    
        _owner        = NULL;     // 标识拥有该monitor的线程
        _WaitSet      = NULL;     // 存储正处于wait状态的线程
        _WaitSetLock  = 0 ;   
        _Responsible  = NULL ;
        _succ         = NULL ;
        _cxq          = NULL ;    // 存放竞争失败线程的单向链表
        FreeNext      = NULL ;
        _EntryList    = NULL ;    // 存储等待锁block状态的线程
        _SpinFreq     = 0 ;
        _SpinClock    = 0 ;
        OwnerIsThread = 0 ;
        _previous_owner_tid = 0;
    }
    
    

    挑几个比较重要的来说一下:

    _recursions:这个在上文讲monitorenter指令的时候有提到,就是记录线程线程获取锁的次数,获取到锁该属性则会+1,退出同步代码块则-1;

    _owner:当一个线程获得了monitor的所有权,则该对象会保存到_owner中。

    _WaitSet:当线程入wait状态,则会存储到_WaitSet当中。

    _cxq :当线程之间开始竞争锁,如果锁竞争失败后,则会加入_cxq链表中。

    _EntryList:当新线程进来尝试去获取锁对象,又没有获取到对象的时候,则会存储到_EntryList当中。

    5.2 monitor 竞争

    什么情况下会竞争?

    当多个线程执行同步代码块的时候,这个时候就会出现锁竞争。

    当线程执行同步代码块时,先执行monitorenter指令, 这个时候会调用interpreterRuntime.cpp中的函数

    源文件如下:src/share/vm/interpreter/interpreterRuntime.cpp,搜索:monitorenter

    IRT_ENTRY_NO_ASYNC(void, InterpreterRuntime::monitorenter(JavaThread* thread, BasicObjectLock* elem))
      // 代码省略
     
      // 是否用偏向锁
      if (UseBiasedLocking) {
        // Retry fast entry if bias is revoked to avoid unnecessary inflation
        ObjectSynchronizer::fast_enter(h_obj, elem->lock(), true, CHECK);
      } else {
          // 重量级锁
        ObjectSynchronizer::slow_enter(h_obj, elem->lock(), CHECK);
      }
      
      // 代码省略
    IRT_END
    
    

    线程之间如何竞争锁的?

    对于重量级锁,monitorenter函数中会调用 :ObjectSynchronizer::slow_enter,

    最终调用到这个函数上:ObjectMonitor::enter,源码位于:/src/share/vm/runtime/objectMonitor.cpp

    void ATTR ObjectMonitor::enter(TRAPS) {
      // The following code is ordered to check the most common cases first
      // and to reduce RTS->RTO cache line upgrades on SPARC and IA32 processors.
      Thread * const Self = THREAD ;
      void * cur ;
     
      // 1、通过CAS操作尝试把monitor的_owner设置成当前线程
      cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
      if (cur == NULL) {
         assert (_recursions == 0   , "invariant") ;
         assert (_owner      == Self, "invariant") ;
         return ;
      }
     
      // 2、重入锁
      if (cur == Self) {
         // 重入锁计数器也需要+1
         _recursions ++ ;
         return ;
      }
     
      // 3、如果是当前线程第一次进入该monitor
      if (Self->is_lock_owned ((address)cur)) {
        assert (_recursions == 0, "internal state error");
        // 计数器+1
        _recursions = 1 ;
        // 把当前线程设置赋值给_owner
        _owner = Self ;
        OwnerIsThread = 1 ;
        return ;
      }
     
       // TODO-FIXME: change the following for(;;) loop to straight-line code.
       for (;;) {
          jt->set_suspend_equivalent();
     
          // 4、获取锁失败,则等待锁释放
          EnterI (THREAD) ;
     
          if (!ExitSuspendEquivalent(jt)) break ;
     
      
          _recursions = 0 ;
          _succ = NULL ;
          exit (false, Self) ;
     
          jt->java_suspend_self();
        }
    }
    
    

    此处省略了锁的自旋优化等操作,文章后面会讲到

    以上代码具体的操作流程如下:

    1)通过CAS尝试把monitor的_owner属性设置为当前线程

    2)如果之前设置的owner等于当前线程,说明当前线程再次进入monitor,即重入锁,执行_recursions ++ ; 记录重入次数。

    3)如果当前线程是第一次进入monitor,设置_recursions = 1,_owner = 当前线程,该线程成功获得锁并返回。

    4、如果获取锁失败,等待锁释放

    5.3. monitor 等待

    上文有提到,如果锁竞争失败后,会调用EnterI (THREAD) 函数,还是在objectMonitor.cpp源码中搜索:::EnterI

    以下代码小编省略了部分:

    void ATTR ObjectMonitor::EnterI (TRAPS) {
        Thread * Self = THREAD ;
        assert (Self->is_Java_thread(), "invariant") ;
        assert (((JavaThread *) Self)->thread_state() == _thread_blocked   , "invariant") ;
     
        // 尝试获取锁
        if (TryLock (Self) > 0) {
            assert (_succ != Self              , "invariant") ;
            assert (_owner == Self             , "invariant") ;
            assert (_Responsible != Self       , "invariant") ;
            return ;
        }
     
        // 自旋操作尝试获取锁
        if (TrySpin (Self) > 0) {
            assert (_owner == Self        , "invariant") ;
            assert (_succ != Self         , "invariant") ;
            assert (_Responsible != Self  , "invariant") ;
            return ;
        }
     
        // 当前线程封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ
        ObjectWaiter node(Self) ;
        Self->_ParkEvent->reset() ;
        node._prev   = (ObjectWaiter *) 0xBAD ;
        node.TState  = ObjectWaiter::TS_CXQ ;
     
        // 通过CAS把node节点push到_cxq队列中
        ObjectWaiter * nxt ;
        for (;;) {
            node._next = nxt = _cxq ;
            if (Atomic::cmpxchg_ptr (&node, &_cxq, nxt) == nxt) break ;
     
            // Interference - the CAS failed because _cxq changed.  Just retry.
            // As an optional optimization we retry the lock.
            // 再次尝试获取锁
            if (TryLock (Self) > 0) {
                assert (_succ != Self         , "invariant") ;
                assert (_owner == Self        , "invariant") ;
                assert (_Responsible != Self  , "invariant") ;
                return ;
            }
        }
     
        // 挂起线程
        for (;;) {
            // 挂起之前再次尝试获取锁
            if (TryLock (Self) > 0) break ;
            assert (_owner != Self, "invariant") ;
     
            if ((SyncFlags & 2) && _Responsible == NULL) {
               Atomic::cmpxchg_ptr (Self, &_Responsible, NULL) ;
            }
     
            // park self
            if (_Responsible == Self || (SyncFlags & 1)) {
                TEVENT (Inflated enter - park TIMED) ;
                Self->_ParkEvent->park ((jlong) RecheckInterval) ;
                // Increase the RecheckInterval, but clamp the value.
                RecheckInterval *= 8 ;
                if (RecheckInterval > 1000) RecheckInterval = 1000 ;
            } else {
                TEVENT (Inflated enter - park UNTIMED) ;
                // 通过park将当前线程挂起,等待锁释放
                Self->_ParkEvent->park() ;
            }
            // 尝试获取锁
            if (TryLock(Self) > 0) break ;
        }
     
        return ;
    }
    
    

    以上代码具体流程概括如下:

    1)进入EnterI后,先会再次尝试获取锁对象

    2)把当前线程封装成ObjectWaiter对象node,状态设置成ObjectWaiter::TS_CXQ ;

    3)在for循环中,通过CAS把node节点push到_cxq(上文有提到这个属性)列表中,同一时刻可能有多个线程把自己到node节点push到_cxq列表中。

    4)node节点push到_cxq 列表之后,通过自旋尝试获取锁,如果还是没有获取到锁,则通过park将当前线程挂起,等待唤醒。

    5)当前线程被唤醒时,会从挂起到点继续执行,通过TryLock再次尝试锁。

    5.4 monitor 释放

    什么时候会释放monitor?

    当线程执行完同步代码块时,调用monitorexit指令释放锁,这个时候锁就会被释放。

    还是在objectMonitor.cpp源码中搜索:::exit

    释放monitor过程是什么?

    exit函数代码如下,当然小编也有大部分的删减,留下比较主要的代码部分。

    void ATTR ObjectMonitor::exit(bool not_suspended, TRAPS) {
     
       // 判断计数器,不等于0则执行-1
       if (_recursions != 0) {
         _recursions--;        // this is simple recursive enter
         TEVENT (Inflated exit - recursive) ;
         return ;
       }
     
       // w = 最后被唤醒的线程
       ObjectWaiter * w = NULL ;
       int QMode = Knob_QMode ;
        
       // QMode == 2,会绕过EntryList队列,从cxq队列中获取线程用于竞争锁
       if (QMode == 2 && _cxq != NULL) {
        w = _cxq ;
        assert (w != NULL, "invariant") ;
        assert (w->TState == ObjectWaiter::TS_CXQ, "Invariant") ;
        // 唤醒线程
        ExitEpilog (Self, w) ;
        return ;
       }
        
      // QMode还有还好几种策略,小编就不一一列举了
     
      // 最后拿到了要被唤醒的线程
      w = _EntryList  ;
      if (w != NULL) {
        guarantee (w->TState == ObjectWaiter::TS_ENTER, "invariant") ;
        // 唤醒线程
        ExitEpilog (Self, w) ;
        return ;
      }
     
     
    }
    
    

    观察以上代码,都需要调用ExitEpilog函数来唤醒线程, 还是在objectMonitor.cpp源码中搜索:::ExitEpilog

    void ObjectMonitor::ExitEpilog (Thread * Self, ObjectWaiter * Wakee) {
       assert (_owner == Self, "invariant") ;
     
       _succ = Knob_SuccEnabled ? Wakee->_thread : NULL ;
       ParkEvent * Trigger = Wakee->_event ;
     
       Wakee  = NULL ;
     
       // Drop the lock
       OrderAccess::release_store_ptr (&_owner, NULL) ;
       OrderAccess::fence() ;                               // ST _owner vs LD in unpark()
     
       if (SafepointSynchronize::do_call_back()) {
          TEVENT (unpark before SAFEPOINT) ;
       }
     
       DTRACE_MONITOR_PROBE(contended__exit, this, object(), Self);
       
       // 最重要的时候这里,调用unpark来进行唤醒
       Trigger->unpark() ;
     
       // Maintain stats and report events to JVMTI
       if (ObjectMonitor::_sync_Parks != NULL) {
          ObjectMonitor::_sync_Parks->inc() ;
       }
    }
    
    

    以上代码具体流程概括如下:

    1)退出同步代码块时会让_recursions - 1,当_recursions的值等于0的时候,说明线程释放了锁。

    2)根据不同的策略(由QMode来指定),最终获取到需要被唤醒的线程(代码中是:w)

    3)最后调用ExitEpilog函数中,最终由unpark来执行唤醒操作。

    六、永恒钻石

    6.1 CAS 介绍

    CAS的英文单词CompareAndSwap的缩写,比较并替换。CAS需要有3个操作数:内存地址V、旧的预期值A、即将要更新的目标值B。

    CAS指令执行时,当内存地址V的值与预期值A相等时,将目标值B保存到内存当中,否则就什么都不做。 整个比较并替换的操作是一个原子操作。

    CAS是乐观锁技术,当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程并不会挂起,而是被告知这次竞争失败,并可以再次尝试。

    优点:可以避免优先级倒置和死锁等危险,竞争比较便宜,协调发生在更细的力度级别,允许更高程度的并行机制等等。

    缺点:

    1、循环时间长开销很大,如果CAS失败,会一直进行尝试,如果CAS长时间一直不成功,可能会给CPU带来很大的开销。

    2、只能保证一个共享的原子操作,我们可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁来保证原子性。

    3、ABA问题,如果内存地址V初次读取的值是A,并且在准备赋值的时候检查仍然为A,那我们就能说它的值没有被其他线程改变过吗?

    如果在这段期间它的值曾被改成了B,后来又被改回A,那CAS就会误认为它从来没有被改变过,这个漏洞称之为CAS操作的ABA问题。Java并发包为了解决这个问题,提供了一个带有标记的原子引用类 “AtomicStampendReference”,它可以通过控制变量值的版本来保证CAS的正确性。

    因此,在使用CAS前要考虑清楚“ABA”问题是否会影响程序并发性的正确性,如果需要解决ABA问题,改用传统的互斥同步可能比原子类更高效

    介绍完CAS,那么肯定就多多少少介绍以下实现原理,我们以AtomicInteger为例,它是JDK中提供能够保障原子性操作的类。

        /**
         * Atomically increments by one the current value.
         *
         * @return the updated value
         */
        public final int incrementAndGet() {
            return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
        }
    
    

    我们点进去看它里面的方法,拿incrementAndGet方法为例子,这个方法是在原有值的基础上进行+1操作,它的实现调用Unfafe类的方法,我们再点进去看。

       public final int getAndAddInt(Object var1, long var2, int var4) {
            int var5;
            do {
                var5 = this.getIntVolatile(var1, var2);
            } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
     
            return var5;
        }
    
    

    Unfafe类使Java拥有了像C语言的指针一样操作内存空间的能力,同时也带来了指针问题,过度的使用Unsafe类会使得出错的几率变大。因此Java官方不建议使用的,Unsafe对象也不能直接调用,只能通过放射来获取。

    小编这里说一下getandAddInt方法的执行流程,

    var1:传进来的是this,也就是AtomicInteger实例对象;

    var2:偏移量,通过结合var1就能够获得在内存中的最新值;

    var4:要进行累加的值,也就是 1 ;

    先通过var1+var2 获取到内存中最新的值,然后再调用compareAndSwapInt方法,这个方法又会通过var1+var2参数获取内存中最新的值,与var5的值进行比较,如果比较成功,这把var5+var4的结果更新到内存中去。如果不成功,则继续循环操作。也就是我们刚刚介绍CAS所说,比较并替换。

    6.2 sync 锁升级过程

    在JDK1.5以前,sync是一个重量级的锁,在1.6以后,对sync做了大量的各种优化,包含偏向锁、轻量级锁、适应性自旋、锁消除、锁粗化等等,这些技术都是为了线程之间更加高效的共享数据,以及解决竞争问题,从而达到程序的执行效率。

    当然锁肯定升级的过程:无锁 —— 偏向锁 —— 轻量级锁 —— 重量级锁。

    每个不同的锁都有不同的使用藏场景,在了解各种锁的特性之前,我们还需要搞清楚对象在内存中的布局!

    6.3 对象的布局

    我们每一个对象在内存中分为三块区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)。

    对象头:

    当一个线程尝试访问sync修饰的代码块时,它先要获得锁,这个锁对象是存在对象头中的。

    以Hotspot虚拟机为例,对象头里面主要包含了Mark Word(字段标记)、Klass Pointer (指针类型),如果对象是数组类型,还包含了数组的长度。

    怎么又扯到Hotspot虚拟机呢? 小伙伴可以这样理解,JVM可以理解为一套规范,而Hotspot是具体的虚拟机产品。 就好比如你们要找女朋友、或者男朋友,既然找朋友是不是就要有一定的要求或者规范,JVM就可以看作这个规范,而Hotspot就是具体的男朋友或者女朋友了。

    你不信? System.out.println(System.getProperties()); 运行这个代码吧,找找你们java.vm.name等于什么。

    java.vm.name=Java HotSpot(TM) 64-Bit Server VM

    Mark Word :里默认存储对象的HashCode、分代年龄和锁位标记。 这个也是sync锁实现的重要部分了,在运行期间,Mark Word 里存储的数据会随着锁标位置的变化而变化。 在64位虚拟机下,Mark Word是64bit大小的,其存储结构如图:

    Mark Word 64位虚拟机存储结构

    image.png

    以上这个表格数据不能乱来对不对,我们可以查看源码:src/share/vm/oops/markOop.hpp

    里面注释写的很清楚了,对照以下注释反映出上面的表格,更加直观。

    //  32 bits:
    //  --------
    //             hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
    //             JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
    //             size:32 ------------------------------------------>| (CMS free block)
    //             PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)
    //
    //  64 bits:
    //  --------
    //  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
    //  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
    //  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
    //  size:64 ----------------------------------------------------->| (CMS free block)
    
    

    **Klass Pointer **:用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定是哪个对象的实例。

    对象头 = Mark Word + Klass Point 在未开启指针压缩对情况下所占大小:

    以64位系统为例:Mark Word = 8 bytes,指针类型 = 8 bytes ,对象头 = 16 bytes = 128bits;

    实例数据:

    类中定义的成员变量

    对齐填充:

    对齐填充并不是必然存在的,也没有什么特殊的意义,它仅仅只是占位符的作用。由于HotPort VM的自动内存管理系统要求对象起始地址必须是8字节的整倍数,当对象的实例数据部分没有对齐时,就需要通过对齐填充来不补齐。

    说了这么多,都是概念性的东西,说谁不会说对不对,接下来我们尝试在把一个对象在内存中都布局输出看下:

    先引入这个jar包,它能够提供我们想要看到的东西,使用方式如下:

    <dependency>
        <groupId>org.openjdk.jol</groupId>
        <artifactId>jol-core</artifactId>
        <version>0.10</version>
    </dependency>
    
    
    public class SyncExample4 {
     
        static Apple apple = new Apple();
     
        public static void main(String[] args) {
            // 这里使用ClassLayout来查看
            System.out.println(ClassLayout.parseInstance(apple).toPrintable());
        }
    }
     
    class Apple {
        private int count;
        private boolean isMax;
     
    }
    
    

    以下内容就是我们Java对象内存分布所查看到的内容,我们能直接看到内容有object header 翻译过来就是对象头呀, 再往下看就是loss due to the next object alignment,这个就是对齐填充,由于Apple 有一个boolean的属性,占了一个字节,所以计算机为了提高执行效率和GC垃圾回收的效率,进行了7个字节的填充(这里涉及到CPU运行小编就不多扯了)。

    com.example.concurrency.sync.Apple object internals:
     OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
          0     4           (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
          4     4           (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
          8     4           (object header)                           43 c0 00 f8 (01000011 11000000 00000000 11111000) (-134168509)
         12     4       int Apple.count                               0
         16     1   boolean Apple.isMax                               false
         17     7           (loss due to the next object alignment)
    Instance size: 24 bytes
    Space losses: 0 bytes internal + 7 bytes external = 7 bytes total
    
    

    看到这里我们确实能够确定对象头的存在,那么对象头里面不是说用31 bit存储了HashCode吗? 怎么没看见

    我们再来执行一段代码, 计算一下apple的HashCode是多少,看运行结果可知,本次运行apple的HashCode是7ea987ac,我们再看看对应VALUE值也发生了改变。这里有一个概念,由于存在大小端存储方式,我们需要从后往前看。

    public class SyncExample4 {
     
        static Apple apple = new Apple();
     
        public static void main(String[] args) {
            // 查看HashCode
            System.out.println(Integer.toHexString(apple.hashCode()));
            System.out.println(ClassLayout.parseInstance(apple).toPrintable());
        }
    }
     
    class Apple {
        private int count;
        private boolean isMax;
    }
    
    
    7ea987ac
    # WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
    com.example.concurrency.sync.Apple object internals:
     OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
          0     4           (object header)                           01 ac 87 a9 (00000001 10101100 10000111 10101001) (-1450726399)
          4     4           (object header)                           7e 00 00 00 (01111110 00000000 00000000 00000000) (126)
          8     4           (object header)                           43 c0 00 f8 (01000011 11000000 00000000 11111000) (-134168509)
         12     4       int Apple.count                               0
         16     1   boolean Apple.isMax                               false
         17     7           (loss due to the next object alignment)
    Instance size: 24 bytes
    Space losses: 0 bytes internal + 7 bytes external = 7 bytes total
    
    

    细心一点的小伙伴就会发现,上文不是说了对象头一共占了16个字节吗? 这里三个object header 才12个字节也不对呀?

    这里JVM默认会开启指针压缩,我们可以通过参数把它关掉:

    image

    在打印看结果,就是16个字节。

     OFFSET  SIZE                                        TYPE DESCRIPTION                               VALUE
          0     4                                             (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
          4     4                                             (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
          8     4                                             (object header)                           80 68 f5 1f (10000000 01101000 11110101 00011111) (536176768)
         12     4                                             (object header)                           02 00 00 00 (00000010 00000000 00000000 00000000) (2)
    
    

    最后总结以下: Java对象有三个部分组成:对象头、实例数据、对齐填充,其中对象头又包含Mark Word、Klass Pointer(如果对象是数组类型,还包含了数组的长度)。

    七、至尊星耀

    Mark Word 64位虚拟机存储结构

    image.png

    7.1 偏向锁

    偏向锁的原理

    在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得。为了让线程获得的锁的代价更低,从而引入偏向锁的。

    我们对照Mark Word存储结构来看,当一个线程访问同步代码快之后,会把Mark Word中的偏向锁标识由0改为1,并且存储当前线程的ID,以后该线程进入和退出同步代码的的时候,则不需要进行CAS操作来加锁和解锁。只需要简单的测试一下对象头里是否存储着指向当先线程的偏向锁,如果结果成功,表示线程已经获得了锁。如果失败,需要再查看Mark Word中的偏向锁标识是否设置成1,如果没有,则使用CAS竞争锁。

    我们可以使用代码来观察下:

    偏向锁在Java 6 和Java 7中默认是开启的,但是他在应用程序启动几秒钟之后才激活,我们需要先来关闭延迟启动。

    image
    public class SyncExample4 {
     
        public static void main(String[] args) {
            Apple apple = new Apple();
            apple.start();
        }
    }
     
    class Apple extends Thread {
     
        private Object lock = new Object();
     
        @Override
        public void run() {
            synchronized (lock) {
                System.out.println(ClassLayout.parseInstance(lock).toPrintable());
            }
        }
    }
    
    
     OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
          0     4        (object header)                           05 d8 86 22 (00000101 11011000 10000110 00100010) (579262469)
          4     4        (object header)                           9c 7f 00 00 (10011100 01111111 00000000 00000000) (32668)
          8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
         12     4        (loss due to the next object alignment)
    Instance size: 16 bytes
    Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
    
    

    由于大小端存储,原本偏向锁和锁位标识是在最后的,现在我们需要看最前8位数:00000101

    第一个1 代表是偏向锁,并且锁标识01,和我们的表格也能够对应上。

    偏向锁的撤销

    偏向锁使用了一种等到竞争出现了才释放锁的机制,所以当其他线程来进行争夺锁的时候,持有偏向锁的线程才会释放锁。但是偏向锁的撤销的时候,需要等到一个全局安全点,也就是在这个时间点上没有正在执行的字节码。 它首先会暂停所有线程(包括拥有偏向锁的线程),然后在判断当前是不是偏向锁,如果偏向锁标识等于1,就撤销回0;

    偏向锁的好处

    偏向锁的好处也很显而易见,只有同一个线程来访问同步代码块的时候,效率是很高的,只需要判断当先线程和Mark Word里面存储的线程是否是一致就行了。如果程序中大多数的锁都是不同的线程来进行访问,那么这个时候偏向锁就是多余的了。

    我们可以通过JVM参数来关闭偏向锁:-XX:-UseBiasedLocking

    7.2 轻量级锁

    什么是轻量级锁

    轻量级锁是在JDK6中加入的新型锁机制,引入轻量级锁的目的是为了,在多线程交替执行同步代码块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多线程在同一时刻进入临界区,会导致轻量级锁膨胀升级为重量级锁,所以轻量级锁的出现并非代替重量级锁。

    栈桢

    我们在JVM虚拟中,有堆和栈,而在栈中还包含了我们对象的各种方法,一个方法就相当于一个“栈桢”。其中方法中也是可以存储内容的,其中就包含了Displaced Mark Word,这个有什么作用呢? 接着往下看

    image

    轻量级锁原理

    线程在执行同步代码快之前,JVM会现在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word 复制到锁记录当中。这个就是我们刚刚所说Displaced Mark Word了。JVM利用CAS操作尝试将对象的Mark Word更新为指向锁记录的指针。如果成功,当先线程获得锁并且将锁位标识改为00,如果失败了则需要判断当前对象的Mark Word是否指向当前线程的指针,如果是则表示当线程已经持有对象的锁,执行同步代码快。如果不是只能说明该锁对象被其他线程占用,这时的轻量级需要膨胀到重量级锁,锁位标识改为10,后面的线程进入阻塞状态。

    轻量级锁的释放

    解锁的时候,会使用CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

    7.3 自旋锁

    自旋锁是在JDK1.4中就已经引入了,默认是关闭的,在JDK1.6中默认几句开启了。

    为什么要用自旋锁呢?自旋锁通俗易懂的来说,就是循环去获取锁。 因为在我们锁升级的过程中,如果线程竞争锁失败,就立即被挂起,然后等待被唤醒,其实这个时候性能开销是比较大的。可能线程还正在被挂起的时候,锁已经被释放掉了,所以就有了自旋锁的操作。

    当线程竞争锁失败之后,先自旋来尝试获取锁,如果锁被占用的时间很短,自旋等待的效果就非常好。反之,如果锁被占用的时间很长,那么自旋的线程只会拜拜消耗处理器资源,而不会有任何的作用。自旋默认的默认值是10次,可使用参数-XX:PreBlockSpin来更改。

    适应性自选锁

    由于我们自旋锁可能回带来一定的性能消耗,但是我们又不清楚设置自旋次数多少合适,所以这个时候适应性自选锁就来了。适应性自选就意味着自旋的时间不再固定了,而是由前一次在同一个锁的自旋时间及所得拥有者的状态来决定。假设在同一个同步代码块上自旋10次就能获得锁,那么虚拟机就会认为这次也能够获得锁,还允许自旋的时间稍微长一点。 那么再假设一个同步代码块从来都没有自旋成功过,那么虚拟机就可能省略自旋的过程,以免浪费性能。

    光说还不如来点实际的代码,源码路径:src/share/vm/runtime/objectMonitor.cpp ,搜索::TrySpin_VaryDuration

    int ObjectMonitor::TrySpin_VaryDuration (Thread * Self) {
     // 固定自旋次数
        int ctr = Knob_FixedSpin ;
        if (ctr != 0) {
            while (--ctr >= 0) {
                if (TryLock (Self) > 0) return 1 ;
                SpinPause () ;
            }
            return 0 ;
        }
     
        // 适应式自旋
        for (ctr = Knob_PreSpin + 1; --ctr >= 0 ; ) {
          if (TryLock(Self) > 0) {
            // 成功后,修改自旋的时间
            int x = _SpinDuration ;
            if (x < Knob_SpinLimit) {
               if (x < Knob_Poverty) x = Knob_Poverty ;
               _SpinDuration = x + Knob_BonusB ;
            }
            return 1 ;
          }
          SpinPause () ;
        }
    }
    
    

    7.4 消除锁

    我们先来看以下代码:

     public String getContent() {
            return new StringBuffer().append("a").append("b").append("c").toString();
        }
    
    
     @Override
        public synchronized StringBuffer append(String str) {
            toStringCache = null;
            super.append(str);
            return this;
        }
    
    

    StringBuffer中的append是同步的,但是我们这个getContent这个方法,每次都是新new一个对象来进行操作。所以不同的线程进来,锁住的对象也是不同的,所以就根本不会造成线程上的问题。 这个时候虚拟机即使编译器(JIT)在运行时,对一些代码上的要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,这个就是锁消除。

    7.5 锁粗化

    什么是锁粗化呢? JVM会探测一连串细小的操作都是用同一个对象加锁,将同步代码块的范围放大,放到这串操作的外面,这样只需要加一次锁即可。

     public static void main(String[] args) {
            StringBuffer sb = new StringBuffer();
            for (int i = 0; i < 100; i++) {
                sb.append("a");
            }
        }
    
    
     @Override
        public synchronized StringBuffer append(String str) {
            toStringCache = null;
            super.append(str);
            return this;
        }
    
    

    看上面代码,StringBuffer的append的方法里面是有加同步关键字的,然而我们在外面循环了100次,就要进入锁和退出锁各100次,所以这个时候JVM就会把锁粗化。 把append方法同步关键字去掉,扩大在外面来,就只需要进入和退出1次即可。

     public static void main(String[] args) {
     
            StringBuffer sb = new StringBuffer();
            synchronized (sb) {
                for (int i = 0; i < 100; i++) {
                    sb.append("a");
                }
            }
        }
    
    

    八、最强王者

    终章:平时写代码如何对synchroized优化

    终于打上王者了,不要以为打上王者就行啦,还有一些日常操作我们还需要注意到的。

    减少sync的同步代码块的范围:

    同步代码块精简,执行就会更快,可能轻量级锁、自旋锁就搞定了,不会升级为重量级锁。

       public static void main(String[] args) {
     
            StringBuffer sb = new StringBuffer();
            synchronized (sb) {
                System.out.println("a");
            }
        }
    
    

    降低sync锁的粒度:

    锁的对象也是有讲究的,假设test01和02本身没有任何业务相关的代码,但是锁的对象越是同一个,这样岂不是并发效率就很低了。

    public class SyncExample4 {
     
        public void test01(){
            synchronized (SyncExample4.class){}
        }
        public void test02(){
            synchronized (SyncExample4.class){}
        }
    }
    
    

    读写分离:

    我们尽量可以做到,读的时候不加锁,写入和删除的时候加锁,这样就可以保证多个线程同时来读取数据。

    举个例子:

    HashTable容器竞争激烈的并发环境下,效率低是因为多个线程竞争同一把锁,假如容器有多把锁,每一把锁用于锁住容器中一部分数据,那么多线程访问容器里面不同的数据段的数据时,线程间不会存在锁竞争,从而有效提高并发访问率。这就是ConcurrentHashMap的锁分段技术,将数据分成一段一段的存储,然后把每一段数据分配一把锁,当一个线程占用锁访问其中一段数据的时候,其他段段数据也能被其他线程访问。

    相关文章

      网友评论

        本文标题:从青铜到王者的路线,来聊聊Synchronized底层实现原理

        本文链接:https://www.haomeiwen.com/subject/bcbmiltx.html