Java并发编程(二)之synchronized

前言

上一篇文章我们学习了并发编程中的第一个核心知识点线程,如果有小伙伴没看过可以点这里Java并发编程(一)之线程。这篇文章就带小伙伴们一起学习一下第二个核心知识点—synchronized,这也是面试中只要问道并发必问的知识点。

正文

为什么会出现synchronized

多线程带来的问题

线程的合理使用能够提升程序的处理性能,主要有两个方面,第一个是能够利用多核 cpu 以及超线程技术来实现线程的并行执行;第二个是线程的异步化执行相比于同步执行来说,异步执行能够很好的优化程序的处理性能提升并发吞吐量。但同时也带来了很多问题,比如多线程对于共享数据的访问带来的安全性问题:对于一个共享变量count来说,如果只有一个线程A对它进行访问修改操作,是不会有什么问题的,但是如果多个线程同时都对这个共享变量进行操作,就会出现线程不安全的问题(操作之后得到的实际值跟预期值不一样)

线程安全性

线程安全是并发编程中的重要关注点。对于线程安全性,本质上是管理对于数据状态的访问,而且这个这个状态通常是共享的、可变的。共享,是指这个数据变量可以被多个线程访问;可变,指这个变量的值在它的生命周期内是可以改变的。一个对象是否是线程安全的,取决于它是否会被多个线程访问,以及程序中是如何去使用这个对象的。所以,如果多个线程访问同一个共享对象,在不需额外的同步以及调用端代码不用做其他协调的情况下,这个共享对象的状态依然是正确的(正确性意味着这个对象的结果与我们预期规定的结果保持一致),那说明这个对象是线程安全的。下面我们写一个例子看看:

public class UnsafeDemo {

    private static int count = 0;

    public static void inc(){
        try {
            //这里让线程睡眠一会,表示实际代码中的业务处理
            Thread.sleep(1);
            count++;
        }catch (InterruptedException e){
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0;i < 1000;i++){
            //创建1000个线程对count进行“++”操作,预期结果应该是1000
            new Thread(()->UnsafeDemo.inc()).start();
        }
        Thread.sleep(3000);
        System.out.println("运行结果:" + UnsafeDemo.count);
    }
}

执行结果
在这里插入图片描述
可以看出跟我们预期的值是不一样的,多运行几次会发现结果也可能不一样,这就是线程安全的问题。

如何解决线程安全问题

问题的本质是多个线程并发对共享数据进行了访问、修改等操作,如果我们对多线程进行控制,使得第一个线程在对共享数据没有操作完之前其他线程不得对共享数据进行操作,那么线程安全问题不就解决了吗?如何实现对线程的控制呢?接下来就引入了“锁”的概念,通过对线程“上锁”实现对线程的控制。什么是“锁”呢?“锁”是处理并发的一种同步手段,而如果需要达到前面我们说的一个目的,那么这个锁一定需要实现互斥的特性。Java中提供的加锁方式就是使用synchronized关键字。

synchronized的基本认识

在多线程并发编程中synchronized是一个元老级角色,它也是一种重量级锁,但是,随着 Java SE 1.6 对synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。这块在后续我们会慢慢展开。

synchronized的使用

1.修饰代码块

指定加锁对象,对给定的对象加锁,进入同步代码块之前需要先获得指定对象的锁。这种方式加锁,锁的范围是synchronized后面一对{}所包含的代码块。
代码示例 :

public class SyncDemo1 implements Runnable {

    private static int count = 0;
    private static int flag = 0;

    @Override
    public void run() {
        //这里的代码块是没有加锁的
        for (int j = 0;j < 5;j++){
            flag++;
        }
        //这里的代码块进行加锁
        synchronized (this){
            for (int i = 0;i < 5;i++){
                count++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0;i < 10000;i++){
//            SyncDemo1 syncDemo1 = new SyncDemo1();
            Thread thread = new Thread(syncDemo1,"Thread" + i);
            thread.start();
        }
        Thread.sleep(300);
        System.out.println("运行结果---count:" + count + "   flag:" + flag);
    }

}

执行结果:
在这里插入图片描述
分析:
这里count的操作是加锁的,flag的操作是没加锁的,count的实际结果与我们预期值是一样的,所以它是线程安全的,而flag的值有可能会与预期值不同,所以它是线程不安全的。这里锁住的是SyncDemo1对象的实例,当所有线程公用一个SyncDemo1实例的时候,各个线程之间是“阻塞式”执行的,也就只有执行完该代码块才能释放该对象锁,下一个线程才能执行并锁定该对象,因为锁住的都是同一个对象实例,各个线程之间是互斥的。如果把SyncDemo1的创建放在for循环里面,让每一个线程都去创建一个SyncDemo1对象实例,那么各个线程就会同时(交替)执行,因为此时有多少SyncDemo1对象实例就有多少锁(synchronized只是给当前对象实例加锁),各个锁之间是互不干扰的,此时count的值也会有可能与预期值不同。

2.修饰实例方法

修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。这种方式加锁,锁的范围是整个示例方法。
代码示例:

public class SyncDemo1 implements Runnable {

    private static int count = 0;

    @Override
    public synchronized void run() {
        for (int i = 0;i < 5;i++){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0;i < 10000;i++){
            Thread thread = new Thread(syncDemo1,"Thread" + i);
            thread.start();
        }
        Thread.sleep(300);
        System.out.println("运行结果---count:" + count);
    }

}

执行结果:
在这里插入图片描述
分析:
修饰实例方法和修饰代码块很类似,唯一不同的是两种修饰方式锁的作用范围有所不同。不管是修饰实例方法还是修饰代码块,都要注意两点:

  1. 线程安全的前提是,多个线程操作的是同一个实例,如果多个线程作用于不同的实例,那么线程安全是无法保证的。
  2. 同一个实例的多个实例方法上有synchronized,这些方法都是互斥的,同一时间只允许一个线程操作同一个实例的其中的一个synchronized方法。

3.修饰静态方法

修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁,也就是说锁的对象就是当前类的Class对象。
代码实例:

public class SyncDemo1 implements Runnable {

    private static int count = 0;

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

    public synchronized static void inc(){
        for (int i = 0;i < 5;i++){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
//        SyncDemo1 syncDemo1 = new SyncDemo1();
        for (int i = 0;i < 10000;i++){
            SyncDemo1 syncDemo1 = new SyncDemo1();
            Thread thread = new Thread(syncDemo1,"Thread" + i);
            thread.start();
        }
        Thread.sleep(300);
        System.out.println("运行结果---count:" + count);
    }

}

执行结果:
在这里插入图片描述
分析:
静态方法是属于类的,而不是属于类的实例对象的,我们可以看到,把SyncDemo1 对象的创建放入for循环中,也就是每个线程都有一个SyncDemo1 对象的实例,但是因为我们是为SyncDemo1 类的静态方法加的锁,也就是锁住的是这个类,所以就算是不同的实例,也依然不会影响线程安全。

锁是如何存储的

如果要实现多线程的互斥特效,锁需要具备以下两种因素:

  1. 锁需要有一个东西来表示,比如获得锁是什么状态、无锁状态是什么状态。
  2. 这个状态需要对多个线程共享。

通过对上面锁的基本使用进行分析,synchronized(lock)是基于lock这个对象的生命周期来控制锁的力度范围的,那么锁的存储和这个lock对象有什么关系呢?接下来我们以对象在JVM中的存储作为切入点看看对象中是如何实现锁的,首先我们看一下对象的结构图:
在这里插入图片描述
对象头最核心的两个就是Mark Word和Class Metadata Address,Mark Word结构如上图所示,类型指针Class Metadata Address主要是用于指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。Mark word 记录了对象和锁有关的信息,当某个对象被synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操作都和 Mark word 有关系。Mark Word 在 32 位虚拟机的长度是 32bit、在 64 位虚拟机的长度是 64bit。Mark Word 里面存储的数据会随着锁标志位的变化而变化,Mark Word 可能变化为存储以下 5 种结构:
在这里插入图片描述
每一个锁都对应一个monitor对象,在HotSpot虚拟机中它是由ObjectMonitor实现的(C++实现)。每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor中有两个队列_WaitSet和_EntryList,用来保存ObjectWaiter对象列表(每个等待锁的线程都会被封装ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入_EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。
monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因,同时也是notify/notifyAll/wait等方法存在于顶级对象Object中的原因。
在这里插入图片描述

为什么任何对象都可以实现锁

Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象
oop/oopDesc 进行对应。线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识。

synchronized锁的升级

使用锁能够实现数据的安全性,但是会带来性能的下降。不使用锁能够基于线程并行提升程序性能,但是却不能保证线程安全性。这两者之间似乎是没有办法达到既能满足性能也能满足安全性的要求。hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得锁。所以synchronized在JDK1.6之后做了一定的升级优化,使得它不在那么重了,为了减少获得锁和释放锁带来的性能开销,进而引入了偏向锁、轻量级锁的概念。因此在 synchronized 锁中存在四种状态分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

偏向锁

偏向锁的基本原理

大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代
价更低就引入了偏向锁的概念。当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果存在表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。

偏向锁的获取和撤销逻辑

  1. 首先获取锁 对象的 Markword,判断是否处于可偏向状态。(biased_lock=1、且 ThreadId 为空)。
  2. 如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID写入到 MarkWord。
    (a)如果 cas 成功,就把Markword中的线程ID指向当前线程并且加上偏向锁标记,表示已经获得了锁对象的偏向锁,接着执行同步代码块。
    (b)如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行。
  3. 如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID。
    (a)如果相等,不需要再次获得锁,可直接执行同步代码块。
    (b)如果不相等,说明当前锁偏向于其他线程,需要撤销拥有偏向锁线程的偏向锁并升级到轻量级锁。

偏向锁的撤销

偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象的锁状态升级到轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程有两种情况:
(a)原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向当前线程。
(b)如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块。
下面以图解方式解释上述逻辑:
在这里插入图片描述
在这里插入图片描述
注:在我们的应用开发中,绝大部分情况下一定会存在 2 个以上的线程竞争,那么如果开启偏向锁,反而会提升获取锁的资源消耗。所以可以通过 jvm 参数UseBiasedLocking 来设置开启或关闭偏向锁也就是直接使用轻量级锁。

轻量级锁的加锁和解锁逻辑

锁升级为轻量级锁之后,对象的 Markword 也会进行相应的的变化。升级为轻量级锁的过程:

  1. 线程在自己的栈桢中创建锁记录 LockRecord。
  2. 将锁对象的对象头中的MarkWord复制到线程的刚刚创建的锁记录中。
  3. 将锁记录中的 Owner 指针指向锁对象。
  4. 将锁对象的对象头的 MarkWord替换为指向锁记录的指针。
    在这里插入图片描述
    在这里插入图片描述

自旋锁

轻量级锁在加锁过程中,用到了自旋锁所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而会消耗 CPU 资源。默认情况下自旋的次数是 10 次,可以通过 preBlockSpin 来修改。

在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

轻量级锁的解锁

轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中(简单来说就是将锁状态置为无锁,偏向锁位为"0",锁标识位为"01"),如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁。
在这里插入图片描述

重量级锁的基本原理

因为自旋会消耗CPU,为了避免过多的自旋,一旦锁升级成重量级锁,就不会再 恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
重量级锁的状态下,对象的mark word为指向一个monitor对象的指针。我们对这段代码进行反编译,看看class文件中的信息:

public class MonitorDemo {

    private static int i = 0;

    public static void method(){
        synchronized (MonitorDemo.class){
            i++;
        }
        System.out.println(i);
    }

    public static void main(String[] args) {
        method();
    }
}

在这里插入图片描述
加了同步代码块以后,在字节码中会看到monitorenter 和 monitorexit。从反编译的同步代码块可以看到同步块是由monitorenter指令进入,然后monitorexit释放锁,在执行monitorenter之前需要尝试获取锁,如果这个对象没有被锁定,或者当前线程已经拥有了这个对象的锁,那么就把锁的计数器加1。当执行monitorexit指令时,锁的计数器也会减1。当获取锁失败时会被阻塞,一直等待锁被释放。

但是为什么会有两个monitorexit呢?其实第二个monitorexit是来处理异常的,仔细看反编译的字节码,正常情况下第一个monitorexit之后会执行goto指令,而该指令转向的就是23行的return,也就是说正常情况下只会执行第一个monitorexit释放锁,然后返回。而如果在执行中发生了异常,第二个monitorexit就起作用了,它是由编译器自动生成的,在发生异常时处理异常然后释放掉锁。

每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器。monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能。

重量级锁加锁流程

在这里插入图片描述
任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。

回顾线程竞争机制

为了方便理解,这里举一个例子:

 synchronized (lock){
            //TODO
        }

加入有这样一个同步代码块,现在存在Thread#1、Thread#2等多个线程去访问同步代码块,会有以下几种情况:
情况一:只有Thread#1会进入临界区
情况二:Thread#1和Thread#2交替进入临界区,竞争不激烈
情况三:Thread#1/Thread#2/Thread#2…同时进入临界区,竞争很激烈

偏向锁

此时当 Thread#1 进入临界区时,JVM 会将 lockObject 的对象头 Mark Word 的锁标志位设为“01”,同时会用 CAS 操作把 Thread#1 的线程 ID 记录到 Mark Word 中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于 Thread#1线程,若接下来没有其他线程进入临界区,则 Thread#1 再出入临界区无需再执行任何同步操作。也就是说,若只有Thread#1 会进入临界区,实际上只有 Thread#1 初次进入临界区时需要执行 CAS 操作,以后再出入临界区都不会有同步操作带来的开销。

轻量级锁

偏向锁的场景太过于理想化,更多的时候是 Thread#2 也会尝试进入临界区, 如果 Thread#2 也进入临界区但是Thread#1 还没有执行完同步代码块时,会暂停 Thread#1并且升级到轻量级锁,然后再唤醒Thread#1,最后Thread#2 通过自旋再次尝试以轻量级锁的方式来获取锁。

重量级锁

如果 Thread#1 和 Thread#2 正常交替执行,那么轻量级锁基本能够满足锁的需求。但是如果 Thread#1 和 Thread#2同时进入临界区(Thread#2一直通过自旋获取不到锁),那么轻量级锁就会膨胀为重量级锁,意味着 Thread#1 线程获得了重量级锁的情况下,Thread#2就会被阻塞。

三种锁的优缺点

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级别的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块的场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 自旋时间过长,会消耗CPU 适用于同步块执行速度非常快的场景/追求响应时间
重量级锁 线程竞争不会自旋,不消耗CPU 线程阻塞,响应时间缓慢 适用于同步块执行速度较慢的场景

总结

Java虚拟机中synchronized关键字的实现,按照代价由高到低可以分为重量级锁、轻量锁和偏向锁三种。

  • 重量级锁会阻塞、唤醒请求加锁的线程。它针对的是多个线程同时竞争同一把锁的情况。JVM采用了自适应自旋,来避免线程在面对非常小的synchronized代码块时,仍会被阻塞、唤醒的情况。
  • 轻量级锁采用CAS操作,将锁对象的mark world替换为一个指针,指向当前线程栈上的一块空间(锁记录),存储着锁对象原本的mark world。它针对的是多个线程在不同时间段申请同一把锁的情况。
  • 偏向锁只会在第一次请求时采用CAS操作,在锁对象的标记字段中记录下当前线程的内存地址。在之后的运行过程中,持有该偏向锁的线程的加锁操作将直接返回。它针对的是锁仅会被同一线程持有的情况。

下面以一张图总结synchronized原理:
在这里插入图片描述

结束语

写这篇文章,也参考了很多大佬的文章,收获颇丰,希望对小伙伴们也有所帮助。下面推荐两篇比较好的文章深入理解synchronized底层原理synchronized—深入总结。因为篇幅原因,文中没有详解CAS机制,这也是一块很重要的内容,有兴趣的小伙伴可以去看这个CAS原理 基础篇

练习、用心、持续------致每一位追梦人!加油!!!

猜你喜欢

转载自blog.csdn.net/w1453114339/article/details/105903877