通俗易懂地聊 Java 线程同步(一)

什么是线程同步

线程是 CPU 调度的最小单位,线程如何调度完全是 CPU 说了算,相信大家都知道这一点。比如,有两个线程A、B同时更新内存中的对象O。如果我们不去干预线程的执行,那么这个对象的最终结果是不确定的,如果线程 A 先执行,则为 B 线程的执行结果,或者反之。

再举个通俗的例子,会计准备给小帅发工资 1000 快,但在发之前老板决定扣小帅 500 块。如果老板不跟会计说或者在工资发完之后再说扣款的事,那这个月发的工资就不对了。正确的做法是让老板(线程A)先扣款,在扣款没有完成之前不能够发工资(线程 Block),完了之后再让会计(线程B)去发工资。顺序不能乱,乱了结果就在期望之外。

这就是线程同步,在多个线程操作同一个资源时,保证各线程的执行顺序。

从代码片段说起 片段一

java 复制代码
public class ThreadTest extends Thread {
  boolean flag = true;
  @Override
  public void run() {
    while (flag) {
    }
    System.out.println("thread has finished.");
  }

  public static void main(String[] args) {
    ThreadTest thread = new ThreadTest();
    thread.start();
    try {
      Thread.sleep(1000);
      thread.flag = false;
      Thread.sleep(10);
      System.out.println("main thread has finished.");
    } catch (InterruptedException e) {
      throw new RuntimeException(e);
    }
  }
}

代码分析:主线程里启动一个子线程,在一秒后改变子线程的开关属性,那就是一秒后子线程先结束,然后主线程再结束,输出结果为

arduino 复制代码
thread has finished.
main thread has finished.

然而,是这样么?其实并不是。当我们实际运行代码之后会发现,子线程一直不会结束。 这是为什么?我们明明改变了 falg = false呀,为什么子线程没结束?这就要从 JVM 内存模型说起。

JVM 内存模型

Java 内存模型分为了主内存和工作内存两部分,其规定程序所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(赋值、读取等)都必须在工作内存中进行,而不能直接读取主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递都必须经过主内存的传递来完成。

通俗点说是因为线程读取主内存的数据时,不是直引用,而是将主内存的数据拷贝一个副本至线程的工作内存。线程在工作内存更新之后再同步至主内存,主内存会在适当时候将更新后的数据同步给其他线程。

这样就会存在一个情况,工作内存值改变后到主内存更新一定是需要一定时间的,所以可能会出现多个线程操作同一个变量的时候出现取到的值还是未更新前的值。

本篇文章不会详细讲述 JVM 内存模型相关内容,有需要了的可以阅读这篇博客。从线程三大特性深入理解JMM(Java 内存模型) - 知乎 (zhihu.com)

如何解决------Volatile

上面我们说到,更新工作内存时不会立刻同步到主内存,那我们能不能实时刷新工作内存的对象到主内存呢?答案是可以,这就是 Java 语言关键字 Volatile 的作用。

Volatile 是 Java 提供的一个轻量级同步工具,它能够保证变量的「可见性」。什么意思?一个线程如果修改一个被 Volatile 修饰的变量,则能保证更新后的值对其他线程可见 。就是说其他线程获取到这个变量一定是最新值。 那片段一的代码我们用 Volatile 修饰 flag 变量后:

java 复制代码
volatile boolean flag = true;

改成这样之后,代码便会如我们所期望的「在一秒后改变子线程的开关属性,那就是一秒后子线程先结束,然后主线程再结束」。

片段二

java 复制代码
public class ThreadTest2 {
  int seq = 0;
  void count() {
    seq++;
  }

  public static void main(String[] args) {
    ThreadTest2 threadTest2 = new ThreadTest2();
    new Thread(() -> {
      for (int i = 0; i < 100000; i++) {
        threadTest2.count();
      }
      System.out.println("thread1 seq:" + threadTest2.seq);
    }).start();

    new Thread(() -> {
      for (int i = 0; i < 100000; i++) {
        threadTest2.count();
      }
      System.out.println("thread2 seq:" + threadTest2.seq);
    }).start();
  }
}

看到这段代码,细心的同学可能就会举手说,两个线程同时更新变量 seq,需要用 Volatile 修饰,让线程间彼此可见。

没错,当我们看到多个线程都需要修改同一个变量时,就需要需要线程同步。那我们修改下代码,替换第2行为:

java 复制代码
volatile int seq = 0;

好了,我们再来分析代码。两个线程分别遍历 100000seq++,不管哪个线程先执行完,一定会有一个线程输出 200000,对吧。

当我们运行代码后发现,结果并不是这样,每次结果都不同:

markdown 复制代码
----------------------------
thread1 seq:136035
thread2 seq:153917
----------------------------
thread2 seq:144460
thread1 seq:155928
----------------------------
thread2 seq:130813
thread1 seq:151093
----------------------------

这是为什么呀,线程间不都相互可见么,怎么还与期望不一致?这是因为 seq++ 不是一个原子操作。

原子性

原子是自然界物质的最小单位,在 Java 里原子操作表示 操作不可被中断,要么执行,要么不执行,不可能执行一半后再去执行其他线程 。这个就跟数据库的事务操作类似,事务是数据库操作的最小单位,要么整体成功,要么整体失败。

再说回seq++这个单目操作,实际上自增有两步操作int t = seq + 1; seq = t;,因为是两步操作,所以在多线程执行时有可能第一步执行完后中断执行,切换到另一个线程,这样结果便不是我们期望的了。我们来演示下执行过程:

这就是为什么 seq 最终结果与期望不一致的原因。那么要怎么解决呢?

关键字 synchronized

相信只要做过多线程同步的同学一定知道这个关键字,可能还知道它是一个「互斥锁」。它能保证被修饰的代码块不会被打断执行,要么执行,要么不执行。就是说 synchronized 保证了代码的原子性。举个例子:

java 复制代码
public synchronized void method() {
  System.out.println("method 1");
  System.out.println("method 2");
  System.out.println("method 3");
  System.out.println("method 4");
}

这个方法如果没有 synchronized 修饰,在发生并发时有可能会被任何线程中断。而现在这个方法不管有多少代码,发生并发时,如果当前线程没有执行完一定不会中断执行切换到另一个线程。这就是原子性的实际表现。

同时,上面我们说的 Volatile 关键字会让被修饰的变量具有可见性 ,这个特点在 synchronized 也同时具有。synchronized 代码块会在解锁前将工作内存的内容更新到主内存,所以便保证了可见性

再回到片段二 代码,那是不是在count方法上加上synchronized关键字就可以了?确实是这样,修改如下:

java 复制代码
synchronized void count() {
    seq++;
}

我就不再贴执行结果了,大家有需要可以自己尝试一下。 下面我们来看看 synchronized 是怎么做到让线程互斥的,从它的用法说起。

修饰方法

就比如片段二 代码的 count 方法就是修饰方法,我们来看下它的字节码信息:

可以看到count()字节码有一个 ACC_SYNCHORONIZED 标记,这个标记就表示当前方法是一个同步方法,在进入方法前需要拿到对应的锁,同时锁计数器 +1,在方法结束后锁计数器 -1。如果没有获取到锁则会阻塞当前线程,直到所有锁被释放。

修饰代码块

先看代码和字节码:

java 复制代码
void update(int value){
  synchronized (this){
    seq = value;
  }
}

可以看到字节码里update(int)方法没有 synchronized 标记,在方法内代码块前后多了一对 monitorentermonitorexit 。与修饰方法时类似,在 monitorenter 之前会去获取括号内对象的锁,如果获取到则计数器 +1 ,在 moniterexit 时计数器会 -1,若干获取不到锁则阻塞当前线程。 看起来确实不一样,我们再看代码。修饰代码块需要有一个参数,这个参数应该用什么?还是固定用this?我们继续往下看。

synchronized 本质

先看一段代码

java 复制代码
public class ThreadTest2 {
  private int seq = 0;

  synchronized void count() {
    seq++;
  }

  synchronized void reset(){
    seq = 0;
  }

  void update(int value){
    synchronized (this){
      seq = value;
    }
  }
}

countreset 方法都被 synchronized 修饰,因为 synchronized 的原子性,所以当一个线程在执行count方法时,另一个线程执行 count方法会被阻塞。那(另一个线程)执行reset方法会被阻塞么?答案是会。那执行update方法会被阻塞么?答案是也会?哈?这是为啥?

当使用 synchronized 关键字时,JVM 会为方法或代码块添加一个 monitor 。就是这个 monitor 会判断当前线程是否能拿到这把锁。而修饰方法时,表示的是同一个 monitor ,所以如果有一个线程在执行 count方法,其他线程在执行任何被 synchronized 修饰的方法都会被阻塞,因为它们有相同的 monitor

update 方法会被互斥是因为他们有相同的 monitor 么?对,完全正确,synchronized 的互斥针对的是同一个 monitorsynchronized 修饰方法时就相当于是synchronized(this){}这种写法,它们有一个共同的 monitor 「this」。

关于 monitor 更详细内容本文不做分析,有兴趣的可以阅读这篇博客。深入理解多线程(四)------ Moniter的实现原理 - 知乎 (zhihu.com)

那怎么声明不同的 monitor 呢?很简单,synchronized(?)用不同的参数就行,我们调整下上面的代码。

java 复制代码
public class ThreadTest2 {
  private int seq = 0;
  
  private Object lock = new Object();

  synchronized void count() {
    seq++;
  }

  synchronized void reset(){
    seq = 0;
  }

  void update(int value){
    synchronized (lock){
      seq = value;
    }
  }
  
  int get(){
    synchronized (lock){
      return seq;
    }
  }
}

Object lock = new Object() 这种代码很多人应该不陌生吧?这样调整之后,这个类就有两个 monitorthislock

类锁

前面讲到的 synchronized 不管是修饰方法还是代码块,针对的都是某个对象,锁的作用范围只能在这个对象内,所以也被称为对象锁 。但如果 synchronized 修饰的是一个静态方法或使用 synchronized(xxx.class){} 时,则锁的作用范围就不是对象了,而是这个类。

java 复制代码
public class ThreadTest3 {
  public synchronized static int getValue() {
    return 0;
  }
  public void setValue(int value) {
    synchronized (ThreadTest3.class) {
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
      print(" value:" + value);
    }
  }

  public static void main(String[] args) {
    ThreadTest3 test1 = new ThreadTest3();
    ThreadTest3 test2 = new ThreadTest3();
    ThreadTest3 test3 = new ThreadTest3();

    newThread(1, test1).start();
    newThread(2, test2).start();
    newThread(3, test3).start();
  }

  private static Thread newThread(int i, ThreadTest3 test) {
    return new Thread(() -> {
      long l = System.currentTimeMillis();
      test.setValue(i);
      print("cost time:" + (System.currentTimeMillis() - l));
    });
  }

  static void print(String msg) {
    System.out.println(Thread.currentThread().getName() + " " + msg);
  }
}

静态方法本身就不属于对象而属于类,所以比较容易理解,以上代码就表示同一时间只能有一个线程调用 getValue 方法。setValue 是一个普通方法,而 synchronized 修饰的是 ThreadTest3.class,则表示这个锁会作用于这个类所有的对象实例。

比如这个示例代码,我们创建三个对象实例,用三个线程同时调用 setValue 方法,而由于synchronized(ThreadTest3.class),所以这三个线程不会同时执行,最终耗时3秒执行完成,我们看下运行结果:

less 复制代码
Thread-0  value:1
Thread-0 cost time:1003
Thread-1  value:2
Thread-1 cost time:2009
Thread-2  value:3
Thread-2 cost time:3014

可以看到,三个线程是串行,当前线程会等待上一个线程执行完毕之后再继续。而我们将代码第6行改为synchronized(this),那三个线程就是并行了,看下运行结果:

less 复制代码
Thread-2  value:3
Thread-2 cost time:1006
Thread-1  value:2
Thread-1 cost time:1006
Thread-0  value:1
Thread-0 cost time:1006

这便是「类锁」与「对象锁」的差异,最主要的区别是它们的作用范围不同。

总结

Volatile 主要的作用是让指定资源具有「可见性」(实时更新数据至主内存),但是不具有「原子性」。synchronized 则能让代码同时具备「可见性」和「原子性」。其能互斥线程的本质是 JVM 为代码块加了个 monitor ,这个 monitor 会控制锁的获取释放。「对象锁」、「类锁」分别作用于不同的范围,可以根据自己的需要,选择合适的锁对象。

在不复杂的多线程场景上,synchronized 关键字基本能够胜任,使用也比较简单。有些多线程同步会比较复杂,仅靠 synchronized 处理起来就比较麻烦了。下一篇我将和大家一起聊聊其他线程同步工具,欢迎多多交流!

相关推荐
xlsw_2 小时前
java全栈day20--Web后端实战(Mybatis基础2)
java·开发语言·mybatis
神仙别闹3 小时前
基于java的改良版超级玛丽小游戏
java
黄油饼卷咖喱鸡就味增汤拌孜然羊肉炒饭3 小时前
SpringBoot如何实现缓存预热?
java·spring boot·spring·缓存·程序员
暮湫4 小时前
泛型(2)
java
超爱吃士力架4 小时前
邀请逻辑
java·linux·后端
南宫生4 小时前
力扣-图论-17【算法学习day.67】
java·学习·算法·leetcode·图论
转码的小石4 小时前
12/21java基础
java
拭心4 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
李小白664 小时前
Spring MVC(上)
java·spring·mvc
GoodStudyAndDayDayUp4 小时前
IDEA能够从mapper跳转到xml的插件
xml·java·intellij-idea