Fork me on GitHub

java并发修行之基础篇:线程安全

前言

在互联网应用广泛的今天,软件并发已经成为目前软件开发的必备基础。java作为一门成熟的语言,其拥有着极其高效的并发机制,是目前大中型企业的常用开发语言。想要开发大规模应用,java并发已成为java程序猿们的必备基础技能。

从今天开始,开启java并发修行之路。

什么是线程安全性

线程的安全性总是难以定义的。 在阅读《java并发编程实战》的过程中觉得说的很好:

在线程安全性的定义中,最核心的概念就是正确性。

何为正确性?

某个类的行为与其规范完全一致。

通常我们并不规定类的规范,于是我们通俗对正确性的理解是,单线程的类的行为是按照我们“所见”来运行的,我们确保其可信,“所见即所知”。

于是给出线程安全性的定义:

当多个线程访问某个类时,这个类始终都能表现出正确的行为。

某个对象保证多线程环境下共享、可修改的状态的正确性,那么即是线程安全。

换个角度分析:

  • 当某个类单线程条件都不是正确的,那么其肯定不是线程安全的。
  • 无状态对象一定线程安全
  • 状态不共享或不可修改,即不存在线程安全问题。

如何做到线程安全

线程安全要保证:

  1. 原子性。保证一组相关操作在竞态条件下保证结果可靠。
  2. 可见性。当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
  3. 有序性。避免指令重排序。

解释一下上面三个安全特性:

原子性

关键词:一组相关操作竞态条件

先解释竞态条件

当某个计算的正确性取决于多个线程的交替执行时序时,就会发生竞态条件。

简单的说,其本质就是基于了一个错误的状态去判断或执行计算。

上代码举例。比如一个多线程累加并打印偶数的程序。

程序A:

典型的竞态条件无处理:

public class EchoEvenNumService implements Runnable {
    private int num;
    @Override
    public void run() {
        if (num % 2 == 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.print(num + "\t");
        }

        num++;
    }
}

运行main程序:

public static void main(String[] args) {
    EchoEvenNumService service = new EchoEvenNumService();
    for (int i = 0; i < 100; i++) {
        new Thread(service).start();
    }
}

结果是可想而知的,奇数偶数都有:

0    1    1    2    4    3    2    1    1    9    9    10    9    9    9    9    10    9    18    18    18    20    21    22    20    20    19 ...

程序B

有些同学会觉得,num改成线程安全的类型(AtomicInteger)就可以了,可是事实是这样吗?修改程序A:

public class EchoEvenNumService implements Runnable {
    private AtomicInteger num = new AtomicInteger(0);
    @Override
    public void run() {
        if (num.get() % 2 == 0) {
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.print(num.get() + "\t");
        }
        num.incrementAndGet();
    }
}

运行main方法后,你会发现还是奇数偶数都有:

0    0    2    3    4    5    5    5    5    7    9    6    6    13    14    14    14    15    18    14    17    20    20    17    21    25    25    26    28    28    29    30 ...

这么写是因为没有理解一组相关操作。在上面的程序中,实际上需要做到三个操作:1.num.get() % 2 == 0判断。2.打印偶数。3.num递增。

即时上面三个操作各做各的做到了原子性,但是整体并不是原子性,程序依旧会错误。

程序C

做到整体的原子性,加锁同步。修改程序A:

public class EchoEvenNumService implements Runnable {
    private int num;
    private ReentrantLock lock = new ReentrantLock();
    @Override
    public void run() {
        try {
            lock.lock();
            if (num % 2 == 0) {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.print(num + "\t");
            }
            num++;
        } finally {
            lock.unlock();
        }
    }
}

运行main方法后,程序终于保证了正确性:

0    2    4    6    8    10    12    14    16    18    20    22    24    26    28    30    32    34    36    38    40    42    44    46    48    50    52    54    56    58    60    62    64    66    68    70    72    74    76    78    80    82    84    86    88    90    92    94    96    98    

可见性

保证共享变量有效。 这里需要简单提一下Java的内存模型:

看图说话:

  • 主内存(Main Memory),存储所有变量。
  • 工作内存(Working Memory),保存了该线程使用到的变量的主存副本拷贝。
  • 线程、工作内存、主存三者关系:线程对变量的所有操作(读写等)都必须在工作内存中进行,而不能之间读写主内存中的变量。

如此可见,如果程序没有保证可见性,会使一部分线程读取到的是工作内存中的值(并不一定准确),导致程序不正确执行。

如何保证可见性?手段:加锁,volatile修饰。

有序性

解释下“重排序”现象:

在没有同步的情况下,编译器、处理器以及运行时等都可能对操作的执行顺序进行一些意想不到的调整。

比如赋值两个变量:

private int a;
private int b;

线程A对a,b进行赋值,代码中的逻辑是这样的:

a = 1;
b = 2;

线程A在运行时,对a变量赋值发现a变量在主存中被其他线程加锁不能访问,线程A并不会等待锁释放,它会去尝试获取b变量,当b变量没有被占用时,线程A的执行过程就会变成这样:

b = 2;
a = 1;

这就是JVM内部优化导致的“指令重排序”。

重排序可能导致一些重要的状态值的读取顺序改变导致程序异常甚至会死循环发生OOM。

比如如下程序:

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            while (!ready)
                Thread.yield();
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

这个程序的诡异之处在于,ReaderThread可能永远看不到ready值,更诡异的是ReaderThread的输出可能是0,ReaderThread只读到了ready的值但没有读到number值。这一切“归功于”神奇的“重排序”。

解决方式:同步。

总结

本文java并发修行的第一篇,重在基础。本文简单讲解了线程安全的“定义”,以及线程安全的一些基础概念。核心在于线程并发处理共享变量时的三点保证:原子性可见性有序性。细细体会之,后续准备从源码层面详细对这三点进行分析。

-------------本文结束,感谢您的阅读-------------
贵在坚持,如果您觉得本文还不错,不妨打赏一下~
0%