2. 深入理解Synchronized

2020-08-11  本文已影响0人  说书的苏斯哈

首先看这样一段代码

  static int count = 0;
    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i =0;i<5000;i++){
                    count++;
                }
            }
        },"t1");

        Thread t2 =  new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i =0;i<5000;i++){
                    count--;
                }
            }
        },"t2");

        t1.start();
        t2.start();
        try {
            t1.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        try {
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        log.debug("最终count的值 = {}",count);
    }

对于共享变量count,在一个线程中循环5000次自加,在另一个线程中循环5000次自减,等两个线程都运行结束之后,打印出count的值并不等于0,
这是因为对于count++来说,在字节码中的指令时这样的

       0: getstatic     #2                  // Field count:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field count:I

这里分为了四步操作

  1. 取出静态变量count
  2. 取出数字1
  3. 执行两者相加
  4. 放入到静态变量中

同理count--也是同样的操作

另外JMM也定义了线程对变量的操作并不是直接在主内存读写,而是读取到线程的工作内存中,等工作内存操作完毕之后再写入到主内存中来,这样就会出现当一个线程操作完自加之后还没有写入到主内存中时,发现了线程上下文切换,另一个线程读取主内存时 还是初始值,这时候完成了自减并写入主内存中,发生上下文切换时,第一个线程开始往里面写入之前的值,就会造成数据的覆盖,这也是为什么最终的数值并没有如逾期一样

临界区

竞态条件(Race Condition)

Synchronized

synchronized 就是我们常说的对象锁,它是采用互斥的方式让同一时刻最多只有一个线程持有对象说,其他线程再想获取这个对象锁时就会阻塞住,这样保证了拥有锁的线程可以安全的执行临界区代码,不用担心线程的上下文切换

值得注意的是在java中互斥和同步都可以采用synchronized关键字

  • 互斥是保证临界区的竞态条件发生时,同一时刻只能有一个线程执行临界区代码
  • 同步是由于线程执行的先后,顺序不同,需要一个线程等待其他线程运行到某个点

synchronized语法

    synchronized(对象){
        临街区
    }

在方法上的synchronized是这样的

public class Test {
    public synchronized void test(){
    }
}

等价于

public class Test {
    public void test(){
        synchronized (this){
        }
    }
}

这两个方法表示的是针对成员方法而言锁住的对象实例

public class Test {
    public static synchronized void test() {
    }
}

等价于

public class Test {
    public void test(){
        synchronized (Test.class){
        }
    }
}

对于静态方法而言,锁住的是类对象

变量的线程安全

成员变量和静态变量
局部变量
局部变量自身是安全的
public class Test {
    public void test(){
        int i= 10;
        i++;
    }
}

这样一个代码,查看字节码是这样的

  public void test();
    Code:
       0: bipush        10
       2: istore_1
       3: iinc          1, 1
       6: return

可以看出它的i++的指令是iinc,这是因为局部变量属于线程内部的,当多个线程访问时,会在每个线程的栈帧内存中被创建多份,因此不存在共享,是线程安全的

局部变量的引用

在验证局部变量之前先看一下成员变量的情况

class UnSafeThread{
    ArrayList list = new ArrayList();
    public void method1(Thread thread,int loopNum){
        for(int i = 0;i<loopNum;i++){
            method2();
            method3();
        }
    }
    public void method2(){
        list.add("1");
    }
    public void method3(){
        list.remove(0); }
}

这个类有一个成员变量list,当多个线程调用method1时,对于method2和method3的调用都涉及到对成员变量的读写,这就有竞态条件的发生,因此是线程不安全的
接下来看局部变量引用

  1. 未对外暴露局部变量引用
class SafeThread{
    public void method1(Thread thread,int loopNum){
        ArrayList list = new ArrayList();
        for(int i = 0;i<loopNum;i++){
            method2(list);
            method3(list);
        }
    }
    private void method2(ArrayList list){
        list.add("1");
    }
    public void method3(ArrayList list){
        list.remove(0);
    }
}

这个类局部变量list相当于在每个线程的栈帧中都有副本,因此多个线程访问时,结果都是可预期的,因此是线程安全的
2.对外暴露局部变量引用

class SafeThread{
    public void method1(Thread thread,int loopNum){
        ArrayList list = new ArrayList();
        for(int i = 0;i<loopNum;i++){
            method2(list);
            method3(list);
        }
    }
    public void method2(ArrayList list){
        list.add("1");
    }
    public void method3(ArrayList list){
        list.remove(0);
    }
}

还是原来的代码,只不过把method2和method3的修饰符修改为public,这样一来,子类就可以重写这两个方法 如下

class ChildUnSafeThread extends SafeThread{
    @Override
    public void method3(ArrayList list) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                log.debug("size = {}",list.size());
                list.remove(0);
            }
        }).start();
    }
}

这样在子类的方法中新建一个线程,同样也做到多线程都访问共享资源,也会造成竞态条件的发生,那么这个类就不是线程安全的类

通过上面的例子也可以看出访问修饰符 private,final 在一定程度上是可以保证线程的安全的

常见的线程安全的类

所谓线程安全就是指多个线程调用类的同一个实例的某个方法是安全的,这里需要注意的两点

  1. 它们的每个方法都是原子的
  2. 但是它们的多个方法的组合不一定是原子的

这些常见的线程安全的类有:

对于String,Integer这些不可变类肯定是线程安全的类,因为它们实例的属性是没有修改过的,虽然String的subString和replace看上去改变了值,但是实际上它们内部是又创建了新的对象
对于线程安全的类一般都是使用synchronized来加锁,正如上面所说,虽然这些线程安全的类的每个方法都是原子的但是不代表多个方法组合起来还是线程安全的

比如对于HashTable来说,它的put和get方法都是线程安全的 但是如果结合起来使用就未必是线程安全的 比如下面的代码

 Hashtable<String,String> table = new Hashtable<>();
    public void putIfNull(String key,String value){
        if(table.get(key) ==null){
            table.put(key, value);
        }
    }

上面的代码在多线程访问的情况下就是不安全的,当两个线程t1和t2同时执行这段代码时 有可能t1执行到get方法时发生了上下文切换,这时候t2执行get方法,然后执行put方法,再次发生上下文切换,再执行线程t1的put方法这样就把线程2的put的值给覆盖掉了,产生了不可预期的结果,因此是线程不安全的,如果想要线程安全,需要在putIfNull上面再添加一个synchronized 关键字

对象头

32JVM对象头结构
  1. 普通对象头结构

普通对象的头结构是64bits也就是8个字节,分别是四个字节的MarkWord和四个字节的Klass Word
如下图所示

Mark word(32 bits) Klass Word(32 bits)
  1. 数组对象头结构

对象数组与普通对象头结构的区别是在后面多了个四个字节的数组长度,因此是12个字节

Mark word(32 bits) Klass Word(32 bits) array length(32bits)

对于基本数据类型int 来说,它占用的是四个字节,但是如果用到包装类Integer的话,它不仅需要内容的四个字节 还需要对象头的八个字节,其内存占用是基本数据类型的3倍,这也是为什么能用基本类型就不用其包装类的原因

  1. Mark Word 结构

Mark Word 是用来存储一个对象的状态信息的结构,通常根据它的后两位可以分为五种状态

a. 首先看正常状态的MarkWord的结构

identity_hashCode(25 bits) age(4 bits) biased_lock(1 bits) flag(2 bits)

其中flag在正常状态下就是01

biased_locked: 是否是偏向锁标记,正常状态是 0,如果是偏向锁为1,这也是为什么正常状态和偏向锁状态在最后两位都是01的原因

age:表示的是分带年龄,因为是四个字节,可以看出一个对象的最大的年龄是15

identity_hashCode:对象标识Hash码

b. 偏向锁状态结构

thread(23 bit) epoch(2 bits) age(4 bits) biased_lock(1 bits) flag(2 bits)

偏向锁的结构跟正常状态的差不多,flag跟正常状态是一样的,都是01

biased_lock在变相说状态下是1,

thread:持有偏向锁的线程ID

epoch: 偏向时间戳

c. 轻量级锁状态结构

ptr_to_lock_record(30 bits) flag(2 bits)

ptr_to_lock_record: 指向栈中锁记录的指针

flag:此时为00

d. 重量级锁状态结构

ptr_to_heavyweight_monitor(30 bits) flag(2 bits)

ptr_to_heavyweight_monitor: 指向管程Monitor的指针
flag: 此时为10

e. GC状态结构

flag:此时为11

  1. klass Word
    这一部分用于存储对象的类型指针,JVM通过这个指针确定对象是哪个类的实例
64位JVM对象头

通过对比32位对象头更好的理解64位对象头

unused(25bits) identity_hashcode(32bits) unused(1bits) age(4bits) biased_lock(1bits) flag(2bits)
  1. 偏向锁状态下的结构
thread(54) epoch(2bits) unused(1bits) age(4bits) biased_lock(1bits) flag(2bits)
  1. 对于轻量级锁和重量级锁指向栈中锁记录或者指向Monitor都是62位

Monitor

synchronized锁住的是对象,当使用synchronized锁住对象obj时,实际上是该对象obj与一个Monitor对象产生了联系
一个Monitor包括三部分:

实际上当第一个线程执行到synchronized语句时,首先去判断obj对应的Monitor的Owner是否为空,若为空,则将自己线程ID赋值给Owner,表示自己占有了锁,若Owner不为空,表示已经有别的线程占有了锁,则此线程就会进入entrtset队列中,等待占有Owner的线程释放锁,也就是Owner会为空,这时候entrySet的线程会有调度器调度,选出一个线程作为Owner

针对Obj与Monitor的关联,可以通过字节码来查看,首先这样一个简单的语句

   static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
        }
    }

通过javap 反编译之后可以看出

  public static void main(java.lang.String[]);
    Code:
       0: getstatic    
       3: dup
       4: astore_1
       5: monitorenter
       6: aload_1
       7: monitorexit
       8: goto          16
      11: astore_2
      12: aload_1
      13: monitorexit
      14: aload_2
      15: athrow
      16: return
    Exception table:
       from    to  target type
           6     8    11   any
          11    14    11   any

逐一解释一下这些命令行

  1. 首先取到静态变量也就是Obj对象
  2. 复制一份
  3. 把复制的Obj对象存起来
  4. 进入Monitor
  5. 把第四步存起来的Obj备份取出来
  6. 出Monitor
  7. 跳到16语句也就是return
    11到14的语句的意思是如果在临界区发生异常,这时候在异常里面同样取到之前存起来的对象用于解锁,这样可以做到即使发生异常,也会有渠道释放锁

轻量级锁与锁膨胀

对于刚才Monitor的理论其实是在竞态条件发生时使用synchronized的一种现象,如果不存在同时多个线程同时调用synchronized代码块,则首先会使用轻量级锁

可以使用org.openjdk.jol.info.ClassLayout来打印对象的头信息,其实我们最重要的是要看MarkWord的信息,当然对于自己JVM是32位和64位也必须清楚,因为不同的JVM的位数是不同的可以通过System.getProperty("sun.arch.data.model")来查看

首先对于出现的MarkWord给予必要的解释

01 00 00 00 (00000001 00000000 00000000 00000000) (1)              00 00 00 00 (00000000 00000000 00000000 00000000) (0)

这是64位JVM上得出的Object的MarkWord,首先明确的是这是一个小端存储,也就是说我们分析是的数据是

00 00 00 00 00 00 00 01

根据上面对象头MardWord 64位的结构分析,最后三位是001也就是说这是一个没有偏向锁的正常状态的对象头,虽然它的前面都是0,但是也需要再次的说明的下,它的前25位表示没有用,紧跟着32表示HashCode,然后又1位表示没有用到,后面四位就是年龄

对于当只有一个线程访问synchronized的时候是否是轻量级锁我们使用下面这个代码来验证

public class Monitor {
    static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
            log.debug(ClassLayout.parseInstance(obj).toPrintable());
        }
    }
}

这个代码的意思就是在锁住Obj的时候打印出obj的对象头信息,看看是否符合轻量级锁,打印的结果是

 f8 48 bb 0b (11111000 01001000 10111011 00001011) 
 00 70 00 00 (00000000 01110000 00000000 00000000) 

可以看出f8对应的最后两位是00,根据对象头的结构分析,这个就是轻量级锁,前面的62位指向的是线程中栈帧的锁记录地址

锁记录(Lock Record)

锁记录对象粗略的包含两个东西

加锁过程

当一个线程执行synchronized(obj){}的时候,这时候会检查obj对象头的MardWord是否是正常状态(01),如果是正常状态会在栈帧中创建一个锁记录对象,其中指向对象的指针指向了obj,并会把锁记录对象的地址与obj的MarkWord通过CAS进行交换

  1. 如果CAS交换成功,锁记录的值存储的是obj的MarkWord,对象头中存储的是锁记录对象的地址和状态00,这个也就是我们在对象头中的轻量级锁的结构显示的,后两位是00表示轻量级锁,前面的62位表示的锁记录对象地址
  2. 如果CAS交换失败,这里面分为两种情况
    a. 锁重入:如果说执行了如下代码
public class Monitor {
    static Object obj = new Object();
    public static void main(String[] args) {
        synchronized (obj){
         method1();
        }
    }
    public static void method1(){
        synchronized (obj){

        }
    }
}

当线程的栈帧的锁记录对象再一次与obj进行CAS交换时,这时候obj的MarkWord存储的是同一个线程的上一个锁记录的地址,因此当前的锁记录的值设置为null,并且指向对象的指针同样指向obj,这就是锁冲入,这时候锁记录充当的是重入次数的计数器
b. 锁膨胀:当线程t1的锁记录欲与obj对象MarkWord进行CAS交换时,发现obj的MarkWord的后两位已经变为00了,这表示已经有别的线程持有了obj的轻量级锁,这个时候它就申请了Monitor锁,也就是重量级锁,它把Obj的MarkWord置为指向Monitor的地址,后两位置为10,然后自己进入到entrySet进行等待(此时的Monitor的Owner应该是那个持有轻量级锁的线程)

解锁

持有轻量级锁的对象执行完synchronized的代码时,这时候需要解锁,这时候相当于把锁记录的值与obj的MarkWord进行CAS交换,如果成功表示解锁成功,如果失败也是对标加锁的流程有两种情况

  1. 如果是锁重入,这时候锁记录的值为null,直接去掉锁记录即可
  2. 如果发现obj的MarkWord的后两位变成了10,表示有别的线程触发了锁膨胀,这时候通过MarkWord的重量级锁地址找到Monitor,然后置Owner为空,然后通知entrySet的线程,这样相当于触发了重量级锁的解锁过程

自旋优化

偏向锁

偏向锁撤销,批量重偏向和批量撤销

锁消除优化

  static int x =0;
    public void methed1(){
        x++;
    }
    public void method2(){
        Object obj = new Object();
        synchronized (obj){
            x++;
        }
    }

这两个方法的一个有锁,一个没有锁,但是两个方法的执行时间所差无几,这是因为代码运行的时候有个JIT会对字节码的热点代码进行及时编译,它会再一次的进行优化,因为在method2的锁的对象是局部变量,JIT就认为这不会有竞争,就会在优化的时候进行锁消除,因此看上去是和无锁的状态是一样的

并发度与活跃性

多把锁

在上面的理论与示例中都是共享的同一把锁,这样并发度就降低了,就像是租房子,一个三室一厅的房子一次只租给一个人,可能实际上那个人的需求只是一个房间,因此可以把套房分开各个房间单独租出去,这样各个房间是单独的锁,有效的利用了资源避免了浪费
如下代码

    private Object studyRoom = new Object();
    private Object sleepRoom = new Object();
    public void study(){
        synchronized (studyRoom){
            log.debug("学习");
            try {
                Thread.sleep(1000);
                log.debug("学习完了");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public void sleep(){
        synchronized (sleepRoom){
            log.debug("睡一会");
            try {
                Thread.sleep(1000);
                log.debug("睡醒了");

            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
死锁

如果如上的情况有多把锁,就有可能出现死锁问题,比如有两个线程t1持有锁A,准备请求锁B
t2线程持有锁B,准备请求锁A,那么t1线程一直等待t2线程释放锁B,t2线程一直等待t1线程释放锁A,那么就会出现两个线程一直无限期的等待下去,这就造成了死锁

活锁

如果两个线程互相改变对方的结束条件那么也会造成两个线程结束不了,一直运行下去,这种情况就是活锁,如下代码所示:

 static int count =10;
    public static void main(String[] args) {

        new Thread(new Runnable() {
            @Override
            public void run() {

                while(count>=0){
                    count--;
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.debug("count = {}",count);
                }

            }
        },"t1").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                while(count<=20){
                    count++;
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    log.debug("count = {}",count);
                }
            }
        },"t2").start();
    }
饥饿线程

表示在多个线程中,因为加锁的逻辑问题导致的某些线程经常或者大部分情况不被调度,那么这个线程就是饥饿线程

上一篇 下一篇

猜你喜欢

热点阅读