7张图带你轻松理解Java 线程安全

/ 什么是线程 /

按操做系统中的描述,线程是 CPU 调度的最小单元,直观来讲线程就是代码按顺序执行下来,执行完毕就结束的一条线。java

举个 🌰,富土康的一个组装车间至关于 CPU ,而线程就是当前车间里的一条条做业流水线。为了提升产能和效率,车间里通常都会有多条流水线同时做业。一样在咱们 Android 开发中多线程能够说是随处可见了,如执行耗时操做,网络请求、文件读写、数据库读写等等都会开单独的子线程来执行。面试

那么你的线程是安全的吗?线程安全的原理又是什么呢?(本文内容是我的学习总结浅见,若有错误的地方,望大佬们轻拍指正)数据库

/ 线程安全 /

了解线程安全的以前先来了解一下 Java 的内存模型,先搞清楚线程是怎么工做的。 缓存

Java 内存模型 - JMM安全

什么是 JMM性能优化

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

能用图的地方尽可能不废话,先来看一张图:多线程

上图描述了一个多线程执行场景。线程 A 和线程 B 分别对主内存的变量进行读写操做。其中主内存中的变量为共享变量,也就是说此变量只此一份,多个线程间共享。可是线程不能直接读写主内存的共享变量,每一个线程都有本身的工做内存,线程须要读写主内存的共享变量时须要先将该变量拷贝一份副本到本身的工做内存,而后在本身的工做内存中对该变量进行全部操做,线程工做内存对变量副本完成操做以后须要将结果同步至主内存。架构

线程的工做内存是线程私有内存,线程间没法互相访问对方的工做内存。并发

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

那么问题来了,线程工做内存怎么知道何时又是怎样将数据同步到主内存呢?这里就轮到 JMM 出场了。JMM 规定了什么时候以及如何作线程工做内存与主内存之间的数据同步。

对 JMM 有了初步的了解,简单总结一下原子性、可见性、有序性。

原子性:对共享内存的操做必须是要么所有执行直到执行结束,且中间过程不能被任何外部因素打断,要么就不执行。

可见性:多线程操做共享内存时,执行结果可以及时的同步到共享内存,确保其余线程对此结果及时可见。

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

到这里,咱们能够引出本文的主题了 --【线程安全】。

线程安全的本质

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

public class ThreadDemo {
    private int x = 0;

    private void count() {
        x++;
    }

    public void runTest() {
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 1: " + x);
            }
        }.start();
        new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 1_000_000; i++) {
                    count();
                }
                System.out.println("final x from 2: " + x);
            }
        }.start();
    }

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

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

final x from 1: 989840
final x from 2: 1872479

我运行了10次,其中一个线程输出 x 的值为 2_000_000 只出现了2次。

final x from 1: 1000000
final x from 2: 2000000

出现这样的结果的缘由也就是咱们上面所说的,在多线程环境下,咱们主内存的 x 变量的数据被破坏了。咱们都知道完成一次 i++ 至关于执行了:

int tmp = x + 1;
x = tmp;

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

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

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

/ 线程同步 /

Java 提供了一系列的关键字和类来保证线程安全。

Synchronized 关键字

Synchronized 做用

保证方法或代码块操做的原子性

Synchronized 保证⽅法内部或代码块内部资源(数据)的互斥访问。即同⼀时间、由同⼀个 Monitor(监视锁) 监视的代码,最多只能有⼀个线程在访问。

话很少说来张动图描述一下 Monitor 工做机制:

被 Synchronized 关键字描述的方法或代码块在多线程环境下同一时间只能由一个线程进行访问,在持有当前 Monitor 的线程执行完成以前,其余线程想要调用相关方法就必须进行排队,知道持有持有当前 Monitor 的线程执行结束,释放 Monitor ,下一个线程才可获取 Monitor 执行。

若是存在多个 Monitor 的状况时,多个 Monitor 之间是不互斥的。

多个 Monitor 的状况出如今自定义多个锁分别来描述不一样的方法或代码块,Synchronized 在描述代码块时能够指定自定义 Monitor ,默认为 this 即当前类。

保证监视资源的可见性

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

保证线程间操做的有序性

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

Volatile 关键字

Volatile 做用

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

注意:

  1. Volatile 只对基本类型 (byte、char、short、int、long、float、double、boolean) 的赋值 操做和对象的引⽤赋值操做有效。
  2. 对于 i++ 此类复合操做, Volatile 没法保证其有序性和原子性。
  3. 相对 Synchronized 来讲 Volatile 更加轻量一些。

java.util.concurrent.atomic

包提供了一系列的 AtomicBoolean、AtomicInteger、AtomicLong 等类。使用这些类来声明变量能够保证对其操做具备原子性来保证线程安全。

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

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

Lock

Lock 也是 java.util.concurrent 包下的一个接口,定义了一系列的锁操做方法。Lock 接口主要有 ReentrantLock,ReentrantReadWriteLock.ReadLock,ReentrantReadWriteLock.WriteLock 实现类。与 Synchronized 不一样是 Lock 提供了获取锁和释放锁等相关接口,使得使用上更加灵活,同时也能够作更加复杂的操做,如:

ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
private int x = 0;
private void count() {
    writeLock.lock();
    try {
        x++;
    } finally {
        writeLock.unlock();
    }
}
private void print(int time) {
    readLock.lock();
    try {
        for (int i = 0; i < time; i++) {
            System.out.print(x + " ");
        }
        System.out.println();
    } finally {
        readLock.unlock();
    }
}

/ 总结 /

出现线程安全问题的缘由:

在多个线程并发环境下,多个线程共同访问同一共享内存资源时,其中一个线程对资源进行写操做的中途(写⼊入已经开始,但还没 结束),其余线程对这个写了一半的资源进⾏了读操做,或者对这个写了一半的资源进⾏了写操做,致使此资源出现数据错误。

如何避免线程安全问题?

  • 保证共享资源在同一时间只能由一个线程进行操做(原子性,有序性)。
  • 将线程操做的结果及时刷新,保证其余线程能够当即获取到修改后的最新数据(可见性)。

Android开发资料+面试架构资料 免费分享 点击连接 便可领取

《Android架构师必备学习资源免费领取(架构视频+面试专题文档+学习笔记)》

相关文章
相关标签/搜索