1

并发的学习门槛较高,相较单纯的罗列并发编程 API 的枯燥被动学习方式,本系列文章试图用一个简单的栗子,一步步结合并发编程的相关知识分析旧有实现的不足,再实现逻辑进行分析改进,试图展示例子背后的并发工具与实现原理。

本文是本系列的第一篇文章,提出了一个简单的业务场景,给出了一个简单的串行实现以及基于原子变量的并发实现,同时详细分析了 Java多线程通信、 Java 内存模型、 happy before 等基本概念。

写在前面

文中所有的代码笔者均全部实现了一遍,并上传到了我的 github 上,多线程这部分源码位于java-multithread模块中 ,欢迎感兴趣的读者访问并给出建议^_^

仓库地址:java-learning
git-clone:git@github.com:The-Hope/java-learning.git

串行实现

假定有这样一个需求,给定一个目录和一个关键字,要求统计指定的目录中各文件内指定关键字出现的总次数。

先来看看串行状态下该怎么实现:

/**
 * Description:
 * 扫描指定目录下指定关键字的出现次数——串行版本实现
 *
 * @author The hope
 * @date 2018/5/20.
 */
public class KeywordCount1 implements KeywordCount {

    private String keyword;
    private File directory;

    public KeywordCount1(File directory, String keyword) {

        this.keyword = keyword;
        this.directory = directory;
    }

    public int search() {
        return search(directory);
    }

    private int search(File directory) {
        int result = 0;
        for (File file : directory.listFiles())
            if (file.isDirectory()) result += search(file);
            else result += count(file);
        return result;
    }

    private int count(File file) {
        int result = 0;
        try (Scanner in = new Scanner(file)) {
            while (in.hasNextLine()) {
                String line = in.nextLine();
                if (line.contains(keyword))
                    result++;
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }
        return result;
    }

    @Override
    public void shutDown() {}
}

代码很简单,核心实现是search(File directory) 函数:

private int search(File directory) {
    int result = 0;
    for (File file : directory.listFiles())
        if (file.isDirectory()) result += search(file);
        else result += count(file);
    return result;
}

逻辑很简单,判断当前 file 对象如果是文件夹就递归调用自己,否则统计关键字出现次数。(注,为了方便测试函数的调用,我抽象了接口 KeywordCount 以规范暴露出的方法)

为了看看它的执行效果我们再来写个简单的测试函数:

/**
 * Description:
 *  扫描指定目录下指定关键字的出现次数——测试函数
 * @author The hope
 * @date 2018/5/20.
 */
public class KeywordCountTest {

    public static void main(String... args) throws Exception{
        Scanner in = new Scanner(System.in);
        System.out.println("Enter base directory (e.g. C:\\Program Files\\Java\\jdk1.6.0_45\\src): ");
        String directory = in.nextLine();
        System.out.println("Enter keyword (e.g. java): ");
        String keyword = in.nextLine();
        int execTimes = 5;// 设定执行次数

        long start = System.currentTimeMillis();//开始计时

        int totalCount = 0;
        KeywordCount counter = new KeywordCount1(new File(directory), keyword);
        for (int i = 0; i < execTimes; i++) {
            int count = counter.search();
            totalCount += count;
        }

        long end = System.currentTimeMillis();//结束计时
        System.out.println("Statistics: " + totalCount/ execTimes);
        System.out.println("used time: " + (end-start)/ execTimes);

        counter.shutDown();
    }

}

(为了消除单次运行的波动影响,这里故意写了个循环来做平均)

执行效果如下:

Enter base directory (e.g. C:\Program Files\Java\jdk1.6.0_45\src): 
C:\Program Files\Java\jdk1.6.0_45\src
Enter keyword (e.g. java): 
java
Statistics: 43781
used time: 5152

Process finished with exit code 0

可以看到用时大概在5秒左右

拓展思考

我们可以简单的分析下整个功能的逻辑,大体上可以分为两个部分:

  1. 从给定目录寻找下级文件
  2. 从给定文件中统计指定关键字出现次数

其中第二步明显是相互独立、互不依赖且耗时较多的任务,假使我们能够引入多线程并发的去执行那么就能合理的提升系统的吞吐量进而提高系统响应时间。


注意,在分析是否值得利用多线程改进一个需求实现时,自什么维度来进行任务的拆分是一件比较重要的考虑因素。如果任务之间存在执行顺序依赖或者数据依赖,那么就很难简单的对任务进行拆分,而应该从更高的维度重新思考任务的边界并设计相应的实现。比如,针对有执行顺序依赖的任务,可以从更高维度来对任务进行分组,并将一组任务放入一个线程中顺序执行,并通过 ThreadLocal 来传递变量,这样可以有效减少数据争用的竞态条件。

引入并发

在开始动笔实现之前,我们先来思考这么两个问题:
1. 线程何时执行不受我们控制,我们怎么知道线程何时能够执行完毕
2. 即便我们知道线程什么时候执行完毕,可是 Java 并没有提供线程之间显示的通信方法,那么我们怎么获取需要的结果。

其实这两个问题,都是典型的线程间通信问题。比如第一个问题,换种角度看就是主线程如何接收子线程执行完毕的信息。第二个问题更是一种典型的主线程如何接受子线程计算结果的问题。

所以接下来,我们需要简单的介绍下多线程中的并发通信模型。

多线程间的并发通信

对于多线程编程来说,最根本的就是解决两个问题:

  1. 线程之间如何进行通信(以何种信息来交换信息)
  2. 线程之间如何进行同步

我们先来说说如何通信,大体上有这么两种方式:

  1. 基于消息传递
  2. 基于共享内存

消息传递的并发模型

基于消息传递的并发模型中,线程之间没有公共状态,通信基于显式消息传递实现,由于消息的接收一定存在于消息的发送之后,此时同步是隐式进行的

结合并发模型的介绍,我们可以很容易的知道,Thread.join() 方法就是一种很典型的线程间消息传递机制。他传递的消息就是目标线程何时执行完毕的信息,并兼具阻塞代码执行的功能。类似的消息传递机制还有 wait(),notifyAll() 等方法。

举个栗子:
针对前文的第一个问题:

线程何时执行不受我们控制,我们怎么知道线程何时能够执行完毕?

如下方代码所示。通过使用 Thread.join()方法,保证代码阻塞,直到子线程执行完毕再继续执行:

class ThreadA{
  public static void main(String... args){
    ThreadB b = new Thread(new Runnable{ void run(){...}});
    b.start();
    b.join(); // join() 方法会等待线程执行完毕。如果不加这一行将会继续运行下去
    // do something
  }
}

共享内存的并发模型

基于共享内存的并发模型中,线程之间共享程序的公共状态,通信是通过线程之间串行的对公共状态进行读写来实现的,因此总是需要(程序员)显示的指定同步来实现隐式的通信。

比如 Java 中 volatile,synchronized 以及各种锁机制,均为了解决线程间公共状态的串行访问问题。

讲到这里,我们还可以再宕开一笔,简单聊聊为什么基于共享内存的并发模型一定要花大力气保证线程之间的串行执行。

Java 内存模型的抽象(JMM)

类似现代多核处理器会给每个核心设计自己的 CPU 寄存器缓存主内存中的目标数据,以方便处理器的快速存取。当多个处理器的任务涉及同一块主内存时,就需要利用 MSI、MESI、MOSI 等缓存一致性协议来协调各个处理器之间的对特定内存或者高速缓存的访问规则。如下图:

clipboard.png

针对一个线程对共享变量的写入何时对另一个线程可见问题,Java 利用 JMM 抽象了线程与主内存之间的关系。
我们先来看看Java内存模型(JMM)的示意图:

clipboard.png

注意:这里的工作内存并不实际存在,而是涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化等概念的一种抽象

从图中就可以很清晰的归纳出,如果线程A想要和线程B之间想要通过共享内存进行通信,那么必须经过以下步骤:

  1. 线程A将工作内存中更新的工作内存副本写回至主内存中
  2. 线程B从根据主内存中的值重新更新刷新自己的工作内存副本

上述两步必须有序进行,否则将会导致通信错误。

例如考虑以下时序:

  1. 变量X初始值为100
  2. 线程 A 将 X 值写入工作内存中,此时工作内存与主内存 X 值均为100
  3. 线程 A 给 X + 50 然后写入工作内存中,此时 A 的时间片用完。 X 在工作内存值为 150,X在主内存中值为100
  4. 线程 B 将 X 的值写入自己的工作内存中。此时线程 B 的工作内存值为 100,主内存值仍为 100。
  5. 线程 B 给 X + 30 然后写入工作内存中,此时 B 的工作内存值为 130,主内存值为 100。
  6. 线程 B 将工作内存的值写回主内存,线程 B 运行结束。此时主内存值为 130。
  7. 线程 A 从休眠中醒来,将工作内存中的 150 同步回主内存,此时主内存值为 150。

从上述时序中,我们可以看到,由于线程 A & B 针对共享状态 X 写入并不是串行的,导致中间出现了数据覆盖的错误情况。同理,读者可以再继续分析思考下写读模型中的同步问题。

重排序

值得注意的是,除了上述例子中,线程间错误的时序会导致并发错误,重排序也同样会导致意想不到的并发错误。
重排序的原因大体分为这三种:

  1. 编译期优化的重排序(编译器仅保证不更改单线程运行语义)
  2. 指令级并行的重排序(处理器仅保证不破坏存在数据依赖的指令)
  3. 内存系统的重排序(读/写缓冲区到主内存同步机制)

关于这部分的介绍,前人珠玉在前,列举了大量简明易懂的例子。这里援引并发编程网的程晓明在《深入理解Java内存模型》系列文章中的一个例子来给大家做个简单介绍:

处理器重排序与内存屏障指令

现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!为了具体说明,请看下面示例:

Processor A Processor B
a = 1; //A1
x = b; //A2
b = 2; //B1
y = a; //B2
初始状态:a = b = 0
处理器允许执行后得到结果:x = y = 0

假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果。具体的原因如下图所示:
重排序问题

这里处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(A1,B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(A3,B3)。当以这种时序执行时,程序就可以得到x = y = 0的结果。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样,这里就不赘述了)。

这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作重排序。

上述引文介绍了一个简单的小栗子,说明了重排序问题导致的一个并发错误。既然重排序问题可能导致程序在并发执行时导致意想不到的错误发生,作为程序员我们又该怎么分析定位问题呢?

先行发生(happens before)原则

虽然重排序问题会导致并发程序的可见性错误,不过 Java 通过先行发生的概念重新约定了操作之间的可见性。

换句话说如果一个操作的执行结果需要对另一个线程可见,那么这两个操作之间一定要存在 happens before 关系。这里的两个操作可以是在一个线程也可以是两个线程。

与我们日常开发联系最紧密的先行发生原则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  3. volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
  4. 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。

注:我们常说的 synchronized,volatile,ReentrantLock 等显示同步的原理,就是依托于这里的监视器锁规则实现的。

小结

这里我们介绍了基于共享状态的并发模型,指出了由于线程工作内存与主内存的同步,代码执行的重排序等问题,可能导致线程共享状态的可见性及原子性错误。因此,当线程之间存在公共状态时,需要利用先行发生原则针对共享状态的访问进行合理性分析,确保共享状态的访问/修改操作两两符合先行发生原则。换句话说,需要保证对多线程之间共享状态的操作进行合理同步。

拓展思考

学了这么多,回到我们最开始的问题:

即便我们知道线程什么时候执行完毕,可是 Java 并没有提供线程之间显示的通信方法,那么我们怎么获取需要的结果?

在进行分析之前,我们回过头来看看之前版本的核心代码实现:

int totalCount = 0;
KeywordCount counter = new KeywordCount1(new File(directory), keyword);
for (int i = 0; i < execTimes; i++) {
    int count = counter.search();
    totalCount += count;
}

可以看到,我们最终的结果是通过 totalCount 变量记录的,也就是说,如果我们依旧依赖这个变量作为我们的最重结果,因为每个线程都会统计自己的关键词,累加到该变量。那么这就是一种典型的共享数据的竞态问题,这时依据先行发生原则进行分析,我们发现:

  1. 因为不是单线程环境,所以程序顺序规则失效
  2. 因为没有用任何锁,也没有用 synchronized 关键字,所以监视器规则失效
  3. 因为没有用 volatile 关键字,所以volatile规则失效
  4. 因为上述规则都失效,所以传递性规则也失效

综上,通过利用先行发生原则对竞态条件进行分析,我们发现这部分代码不做改变那么多线程环境下铁定会出错,那么我们接下来该怎么办呢?

解决方法

我们可以新建一个 Counter 类,将这个 Counter 类传递给各个线程去运行计算相应的任务。同时在 Counter 类中设置一个原子的计数器域(AtomicInteger),利用 AtomicIntegerincrementAndGet() 来实现原子的自增操作。等主线程判断计算任务执行完毕时,再从 Counter 类获取计算结果即可。核心代码如下:

class Counter{
    private AtomicInteger count = new AtomicInteger(0);
    
    counter(File file){
        ···
        count.incrementAndGet();
        ···
    }
    
    int getCounterNum(){
        count.get();
    }
}

注:这里由于计数器的实现需要依赖变量自身的旧状态,所以不能使用 volatile 变量。反之,如果业务场景只需要共享状态的单一更新(不依赖旧状态),那么使用 volatile 关键字效率会更高。
拓展来看,如果业务操作再复杂一些,需要确保多个变量的组合操作的并发原子性时,更建议使用 ReentrantLock 以及 synchronized 关键字来对方法或者代码块进行锁定以保证正确性。

基于线程的并发实现

基于上文对并发编程模型的思考,我们解决了摆在我们面前的两尊拦路虎,线程何时结束 & 变量在线程中如何传递。

现在我们终于可以再来看看并发版本的关键字统计功能该如何实现了。代码实现如下:

/**
 * Description:
 *   扫描指定目录下指定关键字的出现次数——多线程+原子变量版本实现
 * @author The hope
 * @date 2018/5/20.
 */
public class KeywordCount2 implements KeywordCount {


    private final File directory;
    private final String keyword;

    KeywordCount2(File directory, String keyword) {

        this.keyword = keyword;
        this.directory = directory;
    }

    public int search() throws InterruptedException {
        Counter counter = new Counter(keyword);
        FileSearch fileSearch = new FileSearch(directory, counter);
        Thread t = new Thread(fileSearch);
        t.start();
        t.join();
        return counter.getCountNum();
    }

    @Override
    public void shutDown() {}

    private static class FileSearch implements Runnable {
        private File directory;
        private Counter counter;

        FileSearch(File file, Counter counter) {
            this.directory = file;
            this.counter = counter;
        }

        @Override
        public void run() {
            List<Thread> subThreads = new ArrayList<>();

            for (File file : directory.listFiles())
                if (file.isDirectory()) {
                    FileSearch fileSearch = new FileSearch(file, counter);
                    Thread t = new Thread(fileSearch);
                    subThreads.add(t);
                    t.start();
                } else {
                    counter.search(file);
                }

            for (Thread subThread : subThreads)
                try {
                    subThread.join();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
        }
    }

    private static class Counter {
        String keyword;
        AtomicInteger count = new AtomicInteger(0);

        Counter(String keyword) {
            this.keyword = keyword;
        }

        int getCountNum() {
            return count.get();
        }

        void search(File file) {
            try (Scanner in = new Scanner(file)) {
                while (in.hasNextLine()) {
                    String line = in.nextLine();
                    if (line.contains(keyword))
                        count.incrementAndGet();
                }
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
        }
    }
}

这里我们新创建了两个类 FileSearchCounter
利用FileSearch来进行线程的创建与子计算的分发问题:

@Override
public void run() {
    List<Thread> subThreads = new ArrayList<>();

    for (File file : directory.listFiles())
        if (file.isDirectory()) {
            FileSearch fileSearch = new FileSearch(file, counter);
            Thread t = new Thread(fileSearch);
            subThreads.add(t);
            t.start();
        } else {
            counter.search(file);
        }

    for (Thread subThread : subThreads)
        try {
            subThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

利用Counter来解决计算结果在线程间的传递问题:

···
AtomicInteger count = new AtomicInteger(0);
···
void search(File file) {
    try (Scanner in = new Scanner(file)) {
        while (in.hasNextLine()) {
            String line = in.nextLine();
            if (line.contains(keyword))
                count.incrementAndGet();
        }
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
}

执行结果如下:

Enter base directory (e.g. C:\Program Files\Java\jdk1.6.0_45\src): 
C:\Program Files\Java\jdk1.6.0_45\src
Enter keyword (e.g. java): 
java
Statistics: 43781
used time: 2418

Process finished with exit code 0

可以看到时间降低至2秒半左右,提高了50%,的确是极大的提高了响应速度

小结

本文通过提出一个简单的业务场景(统计指定目录下关键字出现数量),并设计了一个简单的串行实现。

针对串行版本响应缓慢的问题,笔者以提出问题-解决问题的模式,引入Java多线程通信以及 Java 内存模型的相关知识,一步步解决改造过程中的痛点并最终完成了一个基于原子变量的并发版本实现。

通过测试验证,本轮改造成功解决了串行版本的业务痛点 :)

拓展思考

虽然上述实现极大的提高了程序的执行速度,将执行时间缩短了一半。但是仍然存在下面几个问题。

  1. 代码变得更为复杂: 串行版本50行不到解决问题并发版本,却暴增至100行,客观上增加了复杂度。
  2. 创建线程的数量不可确定: 本版本的实现中,线程的创建数量仅取决于文件数目,衍生出执行效率问题。
  3. 多了些额外的对象,比如 Counter:本问题实际上是问题 1 的具体版本,为了并发而引入新的类本就客观增加了复杂度。
  4. Counter 面临多个线程的竞态条件,必须进行同步:由于使用Counter来解决线程间的通信问题,因而势必引出同步问题。

上述问题该如何解决与避免,请看下文:深入理解 Java多线程系列(2)——执行器框架

未完待续~

参考文献

Java 并发编程实战
Java 核心技术——卷Ⅰ
深入理解 Jvm 虚拟机——周志明
深入理解 Java 内存模型——程晓明

联系作者

zhihu.com
segmentfault.com
oschina.net


Sivan
525 声望90 粉丝

行业分两个赛道,一个是ToC,一个是 ToB。