自从多线程这个概念被发明之后,在开发中就无时无刻不得操心线程安全的问题。

但是,在了解线程安全之前,我们先来了解一下 Java 的内存模型,搞明白线程是如何工作的。

Java 的内存模型 —— JMM

JMM(Java Memory Model),是一种基于计算机内存模型(定义了共享内存系统中多线程程序读写操作行为的规范),屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。保证共享内存的原子性、可见性、有序性

用一张图来展示一个多线程的执行场景:

线程 A 和线程 B 分别对主内存的变量进行读写操作。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。但是线程不能直接读写主内存的共享变量,每个线程都有自己的工作内存,线程需要读写主内存的共享变量时需要先将该变量拷贝一份副本到自己的工作内存,然后在自己的工作内存中对该变量进行所有操作,线程工作内存对变量副本完成操作之后需要将结果同步至主内存。

:::tip
线程的工作内存是线程私有内存,线程间无法互相访问对方的工作内存。
:::

为了便于理解,用图来描述一下线程对变量赋值的流程。

那么问题来了,线程工作内存怎么知道什么时候又是怎样将数据同步到主内存呢? 这里就轮到 JMM 出场了。 JMM 规定了何时以及如何做线程工作内存与主内存之间的数据同步。现在可以介绍刚才提到的三个特性了:

原子性

对共享内存的一次操作(有可能包含有多个子操作)要么全部执行(生效),且中间过程不能被任何外部因素打断;要么全部都不执行(都不生效)。

举个典型的例子,银行转账问题:比如 A 和 B 同时向 C 转账10万元。如果转账操作不具有原子性,A 在向 C 转账时,读取了 C 的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回 C 的账户,此时 B 的转账请求过来了,B 发现 C 的余额为20万,然后将其加10万并写回。然后 A 的转账操作继续——将30万写回 C 的余额。这种情况下 C 的最终余额为30万,而非预期的40万。

可见性

可见性是指,当多个线程并发访问共享变量时,一个线程对共享变量的修改,其它线程能够立即看到。

CPU 从主内存中读数据的效率相对来说不高,现在主流的计算机中,都有几级缓存。每个线程读取共享变量时,都会将该变量加载进其对应 CPU 的高速缓存里,修改该变量后,CPU 会立即更新该缓存,但并不一定会立即将其写回主内存(实际上写回主内存的时间不可预期)。此时其它线程(尤其是不在同一个 CPU 上执行的线程)访问该变量时,从主内存中读到的就是旧的数据,而非第一个线程更新后的数据。

这一点是操作系统或者说是硬件层面的机制,所以很多应用开发人员经常会忽略。

有序性

有序性指的是,程序执行的顺序按照代码的先后顺序执行。在单线程环境下,程序的执行都是有序的,但是在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。

以下面这段代码为例

boolean started = false; // 语句1
long counter = 0L; // 语句2
counter = 1; // 语句3
started = true; // 语句4

从代码顺序上看,上面四条语句应该依次执行,但实际上 JVM 真正在执行这段代码时,并不保证它们一定完全按照此顺序执行。

处理器为了提高程序整体的执行效率,可能会对代码进行优化,其中的一项优化方式就是调整代码顺序,按照更高效的顺序执行代码。

讲到这里,有人要着急了——什么,CPU 不按照我的代码顺序执行代码,那怎么保证得到我们想要的效果呢?实际上,大家大可放心,CPU 虽然并不保证完全按照代码顺序执行,但它会保证程序最终的执行结果和代码顺序执行时的结果一致。

线程安全的本质

其实第一张图的例子是有问题的,主内存中的变量是共享的,所有线程都可以访问读写,而线程工作内存又是线程私有的,线程间不可互相访问。那在多线程场景下,图上的线程 A 和线程 B 同时来操作共享内存里的同一个变量,那么主内存内的此变量数据就会被破坏。也就是说主内存内的此变量不是线程安全的。
我们来看个代码小例子帮助理解。

public class ThreadDemo {
    private int x = 0;

    private void count() {
        x++;
    }

    public void runTest() {
        new Thread(() -> {
            for(int i = 0; i < 1000000; i++) {
                count();
            }
            System.out.println("final x from 1: " + num);
        }).start();
        new Thread(() -> {
            for(int i = 0; i < 1000000; i++) {
                count();
            }
            System.out.println("final x from 2: " + num);
        }).start();
    }

    public static void main(String[] args) {
        new ThreadDemo().runTest();
    }
}

示例代码中runTest()方法2个线程分别执行 10000000 次count()方法,
count()方法中只执行简单的x++操作,理论上每次执行runTest()方法应该有一个线程输出的x结果应该是2000000。但实际的运行结果并非我们所想:

final x from 1: 1010599
final x from 2: 1909131

出现这样的结果的原因也就是我们上面所说的,在多线程环境下,我们主内存的x变量的数据被破坏了。我们都知道完成一次i++相当于执行了两句代码:

int tmp = x + 1;
x = tmp;

在多线程环境下就会出现这种情况:在执行完int tmp = x + 1;这行代码时就发生了线程切换,当线程再次切回来的时候,x就会被重复赋值,导致出现上面的运行结果,2个线程都无法输出2000000

下图描述了示例代码的执行时序:

那么 Java 是如何来解决上述问题来保证线程安全,保证共享内存的原子性、可见性、有序性的呢?

Java 中如何解决线程安全问题

Java 中提供了几种方法,来保证线程安全。

synchronized关键字

保证原子性

synchronized通过Monitor(监视锁)来保证⽅法内部或代码块内部资源(数据)的互斥访问,从而实际目标代码段同一时间只会被一个线程执行,进而保证了目标代码段的原子性。这是一种以牺牲性能为代价的方法。

::: tip
关于 Monitor 和 synchronized 实现原理了解可以看下这2篇文章:Synchronized 的实现原理Moniter 的实现原理
:::

可以看看下面的动图,看看 Monitor 是如何工作的:

synchronized关键字描述的方法或代码块在多线程环境下同一时间只能由一个线程进行访问,在持有当前 Monitor 的线程执行完成之前,其他线程想要调用相关方法就必须进行排队,直到持有当前 Monitor 的线程执行结束,释放 Monitor ,下一个线程才可获取 Monitor 执行。
如果存在多个 Monitor 的情况时,多个 Monitor 之间是不互斥的。

多个 Monitor 的情况出现在自定义多个锁分别来描述不同的方法或代码块,synchronized在描述代码块时可以指定自定义 Monitor ,默认为 this即当前类。

保证可见性

保证多线程环境下对监视资源的数据同步。即任何线程在获取到 Monitor 后的第⼀时间,会先将共享内存中的数据复制到⾃⼰的缓存中;任何线程在释放 Monitor 的第⼀时间,会先将缓存中的数据复制到共享内存中。

保证线程间操作的有序性

synchronized的原子性保证了由其描述的方法或代码操作具有有序性,同一时间只能由最多只能有一个线程访问,不会触发 JMM 指令重排机制

volatile关键字

volatile作用

保证被volatile关键字描述变量的操作具有可见性和有序性(禁止指令重排)

::: tip 注意

  1. volatile只对基本类型 (bytecharshortintlongfloatdoubleboolean) 的赋值操作和对象的引⽤赋值操作有效。
  2. 对于i++此类复合操作, volatile无法保证其有序性和原子性。
  3. 相对synchronized来说volatile更加轻量一些。
    :::

CAS(Compare and Swap)

java.util.concurrent.atomic包提供了一系列的AtomicBooleanAtomicIntegerAtomicLong等类。使用这些类来声明变量可以保证对其操作具有原子性来保证线程安全。

实现原理上与synchronized使用 Monitor(监视锁)保证资源在多线程环境下阻塞互斥访问不同java.util.concurrent.atomic包下的各种原子类基于CAS(CompareAndSwap) 操作原理实现

::: tip CAS
CAS 又称无锁操作,一种乐观锁策略,原理就是多线程环境下各线程访问共享变量不会加锁阻塞排队,线程不会被挂起。通俗来讲就是一直循环对比,如果有访问冲突则重试,直到没有冲突为止。
:::

刚才提到的x++操作,就可以使用AtomicInteger类来完成:

private AtomicInteger x = new AtomicInteger(0);

private void count() {
    x.incrementAndGet();
}

AtomicInteger x = new AtomicInteger();
new Thread(() -> {
    for(int i = 0; i < 1000000; i++) {
        count();
    }
}).start();

现在再检查输出:

final x from 1: 1941014
final x from 2: 2000000

Lock

锁机制由来已久,在 Linux 中也频繁使用了锁机制来保证线程安全。

Java 提供了一系列的锁操作方法。在java.util.concurrent 包下,有一个Lock接口。主要有 ReentrantLockReentrantReadWriteLock.ReadLockReentrantReadWriteLock.WriteLock 实现类。与 synchronized 不同是 Lock 提供了获取锁和释放锁等相关接口,使得使用上更加灵活,同时也可以做更加复杂的操作。

之前的例子可以做出如下修改:

private int num = 0;

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();

private void count() {
    writeLock.lock();

    try {
        num++;
    } finally {
        writeLock.unlock();
    }
}

...

输入结果:

final x from 1: 1707334
final x from 2: 2000000

关于 Lock 实现原理和更详细的使用推荐以下2篇文章:
Lock锁的使用
Lock锁源码分析

一些其他的问题

Q: 既然锁和synchronized即可保证原子性也可保证可见性,为何还需要volatile?

synchronized和锁需要通过操作系统来仲裁谁获得锁,开销比较高,而volatile开销小很多。因此在只需要保证可见性的条件下,使用volatile的性能要比使用锁和synchronized高得多。

Q: 既然锁和synchronized可以保证原子性,为什么还需要AtomicInteger这种的类来保证原子操作?

锁和synchronized需要通过操作系统来仲裁谁获得锁,开销比较高,而AtomicInteger是通过 CPU 级的 CAS 操作来保证原子性,开销比较小。所以使用AtomicInteger的目的还是为了提高性能。

Q: synchronized既可修饰非静态方法,也可修饰静态方法,还可修饰代码块,也能修饰类,有何区别?

  • 修饰一个非静态方法:被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  • 修饰一个静态的方法:其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
  • 修饰一个代码块:被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  • 修饰一个类:是给这个类加锁,作用的对象是这个类的所有对象。

synchronized 当遇见异常时,就会自动释放;而 ReentrantLock 在遇见异常时,不会自动释放,所以,都会在 try-catch 的 finally 块里进行释放

ReentrantLock 可以传入 bool fair 使用公平锁

ReentrantReadWriteLock readLock() 和 writeLock()

wait() 会强制当前线程挂起等待,直到其他线程在同一个对象上调用 notify() 或者 notifyAll()

synchronized :对象头和 monitor。nomitor 是一个保存在对象头中的一个对象。对象头是在新建对象时,在堆中建立了对象头和对象数据,里面包含锁、hashCode 等数据。monitor 使用的是计数器方式来实现同步机制的。它的同步机制是 JVM 对操作系统级别的 Mutex Lock(互斥锁)的管理过程,运行是内核态。

锁自旋:线程的阻塞和唤醒需要 CPU 从用户态转换为核心态,而频繁的阻塞和唤醒对 CPU 来说是负担很重的工作,所以 Java 引入了自旋锁的操作。默认是开启的。所谓自旋,就是执行一段无意义的循环,不会被立即挂起,等待看看当前持有锁的线程是否被很快被释放。缺陷:它要占用 CPU,有可能白白浪费资源。

轻量级锁:对于一块同步代码,虽然会有多个不同线程去执行,但这些线程是在不同的时间段交替请求锁对象,所以不存在锁竞争情况。可以避免阻塞和唤醒操作。适合线程交替执行同步块的志合。如果存在同一时间访问同一个锁的场合,则轻量级锁会变为重量级锁。

重量级锁:synchronized 就是重量级锁。

偏向锁。如果一个线程获得了偏向锁,在接下来的一段时间没有其他线程与它抢占锁,那么持有偏向锁的线程下次再次进入或退出同步代码块,就不需要再进行抢占锁和释放锁的操作。baisedLocking。一旦出现竞争,偏向锁会被撤销,并膨胀成轻量级锁。

DVM 对 JVM 做了哪些优化:

  1. Dalvik 是 Google 公司自己设计的基于 Java 的虚拟机。5.0之前叫DVM,5.0之后叫 ART(Android Runtime)
  2. 大多数实现与 JVM 相同
  3. 使用了 Dex 文件。传统 Java 是将 Java 源代码转换成 .class 文件,而 Android 中是将所有文件打包成 classes.dex 文件,去掉了 class 文件中的冗余字段(比如重复的字符常量),结构也更加紧凑,所以在它的解析阶段,效率会更高,提高了类的查找和加载速度。
  4. DMV 的指令集架构基于寄存器 && 栈堆结构,而 JVM 的指令集是基于栈结构来执行的。指令会变长,但是指令条数会变少,执行更加快速。
  5. Android 系统的第一个 Dalvik 虚拟机是由 Zygote 进程创建的,应用程序是由 Zygote fork 出来的,于是,DVM 内存中的堆被划分为了两部分:ActiveHeap 和 Zygote Heap。Zygote 进程创建 Dalvik 虚拟机时,只有一个堆,但是 Zygote 进行在 fork 第一个应用程序的进程之前 ,会将已经使用的那部分堆内存划分为一部分,其余的划分为另一部分。前者就是 ZygoteHeap,后者就是 ActiveHeap。以后,无论是 Zygote 进程还是应用程序进程,只要使用堆,就在 ActiveHeap 上进行;这就能使用 Zygote 堆能尽量减少拷贝操作。