关于java:并发编程之synchronized

35次阅读

共计 7680 个字符,预计需要花费 20 分钟才能阅读完成。

大家好,我是小黑,一个在互联网得过且过的农民工。

之前的文章中跟大家分享了对于 Java 中线程的一些概念和根本的应用办法,比方如何在 Java 中启动一个线程,生产者消费者模式等,以及如果要保障并发状况下多线程共享数据的拜访平安,操作的原子性,应用到了 synchronized 关键字。明天次要和大家聊一聊 synchronized 关键字的用法和底层的原理。

为什么要用 synchronized

置信大家对于这个问题肯定都有本人的答案,这里我还是要啰嗦一下,咱们来看上面这段车站售票的代码:

/**
 * 车站开两个窗口同时售票
 */
public class TicketDemo {public static void main(String[] args) {TrainStation station = new TrainStation();
        // 开启两个线程同时进行售票
        new Thread(station, "A").start();
        new Thread(station, "B").start();}
}

class TrainStation implements Runnable {
    private volatile int ticket = 10;
    @Override
    public void run() {while (ticket > 0) {System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
            ticket = ticket - 1;
        }
    }
}

下面这段代码是没有做思考线程平安问题的,执行这段代码可能会呈现上面的运行后果:

能够看出,两个线程都买出了 10 号票,这在理论业务场景中是相对不能呈现的。(你去坐火车有个大哥说你占了他的座,让你滚,还说你是票贩子,你气不气)

那因为有这种问题的存在,咱们应该怎么解决呢?synchronized 就是为了解决这种多线程共享数据安全问题的。

应用形式

synchronized 的应用形式次要以下三种。

同步代码块

public static void main(String[] args) {
    String str = "hello world";
    synchronized (str) {System.out.println(str);
    }
}

同步实例办法

class TrainStation implements Runnable {
    private volatile int ticket = 100;

    // 关键字间接写在实例办法签名上
    public synchronized void sale() {while (ticket > 0) {System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
            ticket = ticket - 1;
        }
    }

    @Override
    public void run() {sale();
    }
}

同步静态方法

class TrainStation implements Runnable {
    // 留神这里 ticket 变量申明为 static 的,因为静态方法只能拜访动态变量
    private volatile static int ticket = 100;

    // 也能够间接放在静态方法的签名上
    public static synchronized void sale() {while (ticket > 0) {System.out.println("线程" + Thread.currentThread().getName() + "售出" + ticket + "号票");
            ticket = ticket - 1;
        }
    }
    @Override
    public void run() {sale();
    }
}

字节码语义

通过程序运行,咱们发现通过 synchronized 关键字的确能够保障线程平安,那计算机到底是怎么保障的呢?这个关键字背地到底做了些什么?咱们能够看一下 java 代码编译后的 class 文件。首先来看同步代码块编译后的 class。通过javap -v 名称能够查看字节码文件:

 public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: ldc           #2                  // String hello world
         2: astore_1
         3: aload_1
         4: dup
         5: astore_2
         6: monitorenter            // 监视器进入
         7: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        10: aload_1
        11: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        14: aload_2
        15: monitorexit                // 监视器退出
        16: goto          24
        19: astore_3
        20: aload_2
        21: monitorexit
        22: aload_3
        23: athrow
        24: return

留神看第 6 行和第 15 行,这两个指令是减少 synchronized 代码块之后才会呈现的,monitor是一个对象的监视器,monitorenter代表这段指令的执行要先拿到对象的监视器之后,能力接着往下执行,而 monitorexit 代表执行完 synchronized 代码块之后要从对象监视器中退出,也就是要开释。所以这个对象监视器也就是咱们所说的锁,获取锁就是获取这个对象监视器的所有权。

接下来咱们在看看 synchronized 润饰实例办法时的字节码文件是什么样的。

 public synchronized void sale();
    descriptor: ()V
    // 办法标识 ACC_PUBLIC 代表 public 润饰,ACC_SYNCHRONIZED 指明该办法为同步办法
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED 
    Code:
      stack=3, locals=1, args_size=1
         0: aload_0
         1: getfield      #2                  // Field ticket:I
    // 省略其余无关字节码

能够看到 synchronized 润饰实例办法上之后不会再有 monitorentermonitorexit指令,而是间接在这个办法上减少一个 ACC_SYNCHRONIZED 的 flag。当程序在运行时,调用 sale()办法时,会查看该办法是否有 ACC_SYNCHRONIZED 拜访标识,如果有,则表明该办法是同步办法,这时候还行线程会先尝试去获取该办法对应的监视器(monitor)对象,如果获取胜利,则继续执行该 sale() 办法,在执行期间,任何其余线程都不能再获取该办法监视器的使用权,晓得该办法执行结束或者抛出异样,才会开释,其余线程能够从新取得该监视器。

那么 synchronized 润饰静态方法的字节码文件是什么样呢?

public static synchronized void sale();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED
    Code:
      stack=3, locals=0, args_size=0
         0: getstatic     #2                  // Field ticket:I
      // 省略其余无关字节码

能够看出 synchronized 润饰静态方法和实例办法没有区别,都是减少一个 ACC_SYNCHRONIZED 的 flag,静态方法只是比实例办法多一个 ACC_STATIC 标识代表这个办法是动态的。

以上的同步代码块,同步办法中都提到对象监视器这个概念,那么三种同步形式应用的对象监视器具体是哪个对象呢?

同步代码块的对象监视器就是应用的咱们 synchronized(str) 中的 str, 也就是咱们括号中指定的对象。而咱们在开发中减少同步代码块的目标是为了多个线程同一时间只能有一个线程持有监视器,所以这个对象的指定肯定要是多个线程共享的对象,不能间接在括号中 new 一个对象,这样不能做到互斥,也就不能保障平安。

同步实例办法的对象监视器是以后这个实例,也就是 this。

同步静态方法的对象监视器是以后这个静态方法所在类的 Class 对象,咱们都晓得 Java 中每个类在运行过程中也会用一个对象示意,就是这个类的对象,每个类有且仅有一个。

对象锁(monitor)

下面说了线程要进入同步代码块须要先获取到对象监视器,也就是对象锁,那在开始说之前咱们先来理解下在 Java 中一个对象都由哪些货色组成。

这里先问大家一个问题,Object obj = new Object()这段代码在 JVM 中是怎么的一个内存散布?

想必理解过 JVM 常识的同学应该都晓得,new Object()会在堆内存中创立一个对象,Object obj是栈内存中的一个援用,这个援用指向堆中的对象。那么怎么晓得堆内存中的对象到底由哪些内容组成呢?这里给大家介绍一个工具叫 JOL(Java Object Layout)Java 对象布局。能够通过 maven 在我的项目中间接引入。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

引入之后在代码中能够打印出对象的内存散布。

public static void main(String[] args) {Object obj = new Object();
    // parseInstance 将对象解析,toPrintable 让解析后的后果可输入
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
}

输入后的后果如下:

java.lang.Object 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)                           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

从后果上能够看出,这个 obj 对象次要分 4 局部,每局部的 SIZE= 4 代表 4 个字节,前三行是对象头object header,最初一行的 4 个字节是为了保障一个对象的大小能是 8 的整数倍。

咱们再来看看对于一个加了锁的对象,打印进去有什么不一样?

public static void main(String[] args) {Object obj = new Object();
    synchronized (obj){System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           58 f7 19 01 (01011000 11110111 00011001 00000001) (18478936)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      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 个字节产生了变动,也就是 Mark Word 变了。所以给对象加锁,理论就是扭转对象的 Mark Word。

Mark Word 中的这 8 个字节具备不同的含意,为了让这 64 个 bit 能示意更多信息,JVM 将最初 2 位设置为标记位,不同标记位下的 Mark word 含意如下:

|------------------------------------------------------------------------------|--------------------|
|                                  Mark Word (64 bits)                         |       State        |
|------------------------------------------------------------------------------|--------------------|
| unused:25 | identity_hashcode:31 | unused:1 | age:4 | biased_lock:1 | lock:2 |       无锁态        |
|------------------------------------------------------------------------------|--------------------|
| thread:54 |       epoch:2        | unused:1 | age:4 | biased_lock:1 | lock:2 |       偏差锁        |
|------------------------------------------------------------------------------|--------------------|
|                       ptr_to_lock_record:62                         | lock:2 |      轻量级锁        |
|------------------------------------------------------------------------------|--------------------|
|                     ptr_to_heavyweight_monitor:62                   | lock:2 |      重量级锁        |
|------------------------------------------------------------------------------|--------------------|
|                                                                     | lock:2 |       GC 标记        |
|------------------------------------------------------------------------------|--------------------|

其中最初两位的锁标记位,不同值代表不同含意。

biased_lock lock 状态
0 00 无锁态(NEW)
0 01 偏差锁
1 01 偏差锁
0 00 轻量级锁
0 10 重量级锁
0 11 GC 标记

biased_lock 标记该对象是否启用偏差锁,1 代表启用偏差锁,0 代表未启用。

age:4 位的 Java 对象年龄。在 GC 中,如果对象在 Survivor 区复制一次,年龄减少 1。当对象达到设定的阈值时,将会降职到老年代。默认状况下,并行 GC 的年龄阈值为 15,并发 GC 的年龄阈值为 6。因为 age 只有 4 位,所以最大值为 15,这就是 -XX:MaxTenuringThreshold 选项最大值为 15 的起因。

identity_hashcode:25 位的对象标识 Hash 码,采纳提早加载技术。调用办法 System.identityHashCode() 计算,并会将后果写到该对象头中。当对象被锁定时,该值会挪动到管程 Monitor 中。

thread:持有偏差锁的线程 ID。

epoch:偏差工夫戳。

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

ptr_to_heavyweight_monitor:指向管程 Monitor 的指针。

锁降级过程

既然会有无锁,偏差锁,轻量级锁,重量级锁,那么这些锁是怎么样一个降级过程呢,咱们来看一下。

新建

从后面讲到对象头的构造和咱们下面打印进去的对象内存散布,能够看出新创建的一个对象,它的标记位是 00,偏差锁标记 (biased_lock) 也是 0,示意该对象是无锁态。

偏差锁

偏差锁是指当一段同步代码被同一个线程所拜访时,不存在其余线程的竞争时,那么该线程在当前拜访时便会主动取得锁,从而升高获取锁带来的耗费,进步性能。

当一个线程拜访同步代码块并获取锁时,会在 Mark Word 里存储线程 ID。在线程进入和退出同步块时不再通过 CAS 操作来加锁和解锁,而是检测 Mark Word 里是否存储着指向以后线程的偏差锁。轻量级锁的获取及开释依赖屡次 CAS 原子指令,而偏差锁只须要在置换 ThreadID 的时候依赖一次 CAS 原子指令即可。

轻量级锁

轻量级锁是指当锁是偏差锁的时候,有其余线程来竞争,然而该锁正在被其余线程拜访,那么就会降级为轻量级锁。或者还有一种状况就是敞开 JVM 的偏差锁开关,那么一开始锁对象就会被标记位轻量级锁。

轻量级锁思考的是竞争锁对象的线程不多,而且线程持有锁的工夫也不长的情景。因为阻塞线程须要 CPU 从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被开释了,那这个代价就有点得失相当了,因而这个时候就罗唆不阻塞这个线程,让它自旋这期待锁开释。

在进入同步代码时,如果对象锁状态合乎降级轻量级锁的条件,虚构机会在以后想要竞争锁的线程的栈帧中开拓一个 Lock Record 空间,并将锁对象的 Mark Word 拷贝到 Lock Record 空间中。

而后虚构机会应用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock Record 中的 owner 指针指向对象的 Mark Word。

如果操作胜利,则示意以后线程取得锁,如果失败则示意其余线程持有该锁,以后线程会尝试应用自旋的形式来从新获取。

轻量级锁解锁时,会应用 CAS 操作将 Lock Record 替换回到对象头,如果胜利,则示意没有竞争产生。如果失败,示意以后锁存在竞争,锁就会收缩成重量级锁。

重量级锁

重量级锁是指当有一个线程获取锁之后,其余所有期待获取该锁的线程都会处于阻塞状态。是依赖于底层操作系统的 Mutex 实现,Mutex 也叫互斥锁。也就是说重量级锁会让锁从用户态切换到内核态,将线程的调度交给操作系统,性能相比会很低。

整个锁降级的过程通过上面这张图能更全面的展现。

有须要原图的敌人关注公众号【黑子的学习笔记 】后盾回复“ 锁降级”获取。


好的,明天的内容就到这里,咱们下期再见。

正文完
 0