Java后端学习路线β阶段--Java多线程

197 阅读27分钟

Java后端学习路线,码云仓库地址:gitee.com/qinstudy/ja…

1、多线程是什么?

大榜:今天,我们讨论下Java中的重难点-Java多线程,大家一起来啃下这块硬骨头。

小汪:好啊,我对Java多线程一直比较模糊,我只记得当多个线程访问共享变量时,我们要对该共享变量加锁保护,保证同一时刻只有一个线程得到锁,然后这个线程去访问该共享变量,其他线程只能在“门外”等待。

大榜:不错啊,还记得加锁来保护共享变量。这样把,我们从头开始,一起来慢慢回顾下多线程的基本概念。

小汪:提到多线程,我就条件反射到 多进程,操作系统中有多个应用程序,每一个应用程序都是一个进程,这就是多进程。但是,对于多线程,也就是说一个应用程序中有多个线程,那这个应用程序就是多线程。

大榜:说的很对。总的来说,进程是一个独立的运行环境,而线程是在进程中执行的一个任务。进程和线程的本质区别:是否单独占有内存地址空间及系统资源。

小汪:我记得操作系统书籍上说过:进程是操作系统进行资源分配的基本单位,而线程是操作系统进行调度的基本单元。所以说进程是单独占用内存地址空间和系统资源,而线程就没有单独占用内存地址空间。榜哥,我有个问题啊,既然已经有多进程了,为什么还要有多线程呢?

2、为什么要有多线程?

大榜:其实早期的操作系统,内存中只有一个程序在运行,显然无法满足人们的要求,于是人们开始思考内存中能不能存在多个程序呢?于是,科学家们提出了进程的概念,进程就是应用程序在内存中分配的空间,有几个应用程序就有几个进程,这就是多进程。

小汪:多进程原来是这么来的,我还以为一开始就有了多进程呢,看来多进程是在人们的需求中一步步迭代产生的。那多线程又是如何产生的呢?

大榜:为了把多线程说清楚,我们举个栗子,比如你正在使用杀毒软件,它是一个应用程序,即是一个进程,如果你在使用杀毒软件中的扫描病毒功能时,在扫描结束之前,无法使用杀毒软件中垃圾清理的功能,你觉得这个杀毒软件好用吗?

小汪:如果不能同时使用扫描病毒、垃圾清理这2个功能,那这个杀毒软件肯定不会让我满意的。

大榜:杀毒软件为了让你满意,于是设置了2个线程,一个线程负责扫描病毒功能,另一个线程负责垃圾清理功能。这样,你就可以一边扫描病毒,另一边清理垃圾了,这下应该可以满足你的需求了把。

小汪:用2个线程来处理,确实满足我的需求了。这个杀毒软件中有扫描病毒功能、垃圾清理功能,算是2个子任务,而且这2个子任务是并发执行的,不是串行执行的。

大榜:是滴了,线程让进程内部的并发成为了可能,多线程可以同时在多个处理器上执行,可以充分发挥多CPU的强大计算能力。多线程相比于多进程,有一些优点:

1)进程间通信比较复杂,而线程间通信简单,通常情况下,我们需要使用共享资源,这些资源在线程间通信比较容易;

2)进程是重量级的,而线程是轻量级的,所以说使用多线程的方式,系统开销小。

小汪:嗯嗯。不过我记得线程如果特别多的话,线程之间的上下文切换,系统开销也不小的啊。

大榜:你说得没问题,所以说线程并不是越多越好,如何减少系统中的上下文切换,是提升多线程性能的一个重点课题呢。

小汪:多线程有诸多优点,应该也会有一些缺点把?

大榜:多线程功能强大,如果多线程设计正确,多线程的程序可以提高系统的吞吐率。但编写正确的多线程程序难度有点大,这个算是缺点了,多线程会带来一些风险,主要有下面3点:安全性问题、活跃性问题、性能问题。具体如下:

1)安全性问题:若多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会有线程安全问题。有如下方式可以保证线程安全:

a)不在线程之间共享该状态变量:比如使用线程栈上的局部变量 或者使用ThreadLocal来保证每个线程都有一个对应的状态变量;
​
b)将共享的状态变量修改为不可变的量;
​
c)在访问共享的状态变量时,正确使用同步,比如使用内置锁synchronized 或者显示锁Lock。

2)活跃性问题:包括死锁等问题。

3)性能问题:如果多线程使用不当,会降低应用程序的性能,导致多线程的应用程序的性能可能还不如单线程的应用程序。

小汪:多线程的这3个风险,搞得我都不敢写多线程应用程序了,免得项目上线后,各种线程死锁、性能很差的Bug。

大榜:哈哈哈,是啊,所以说不要轻易编写多线程的应用程序,如果老板给你的需求,通过单线程就可以满足,就没必要用多线程来实现,毕竟大道至简嘛。

小汪:那Java多线程,我们还是要往下学习的,万一哪天遇到了多线程相关的项目,如果完全不会,就可能被“毕业”了。

大榜:哈哈哈,是啊,所以说我们还是要花时间去提升自己,免得哪天被淘汰了。下面我们一起讨论下Java多线程的基本原理,它是通往编写正确的多线程程序的必经之路。

小汪:好啊。

3、Java多线程的原理

大榜:首先我们需要学习Java内存模型,了解重排序与happens-before;接着,引入volatile关键字,保证变量的可见性和禁止指令重排序。然后,学习内置锁synchronized,它有volatile的语义,而且能保证互斥性。进一步,学习比较和交换CAS算法,以及基于CAS算法构建的原子类AtomicXxx。最后,学习AQS抽象队列同步器,它是构建锁、阻塞队列、并发容器、协作工具的基础组件。

小汪:榜哥,你这么一一罗列出来,我顿时觉得Java多线程的原理真不少啊,我下去得好好过一遍这些原理。咱们继续往下讨论把。

大榜:原理是很多,你看书会花很多时间。要不这样把,我先大概讲讲原理,这样你心里有个概念,你回去看书的时候也能更好地抓住重点。首先是Java内存模型,如下图:

image.png

从图中可以看出:

1)所有的共享变量都存在于主内存;

2)每个线程都保存了一份该线程使用的共享变量的副本;

3)线程A无法直接访问线程B的工作内存,线程之间通信必须经过主内存。

举个栗子,线程B并不是直接去主内存中读取共享变量的值,而是先在本地内存B中找到这个共享变量,发现这个共享变量已经更新了,然后本地内存B去主内存中读取这个共享变量的值,并拷贝到本地内存B中,最后线程B再读取本地内存B中的新值。

小汪:那重排序、happens-before又是什么?

大榜:重排序就是编译器和CPU处理器对指令做重排序,可以提高处理速度,但会带来乱序的问题。happens-before顾名思义指的是在...之前发生,你可以大致理解为如果一个操作在另一个操作之前发生,那么第一个操作的执行结果对第二个操作可见,即内存可见性。

小汪:重排序与happens-before感觉好抽象啊,我还是回去看下书,加深理解。我记得volatile关键字可以禁止JVM对指令做重排序,还能保证多线程操作共享变量的可见性。

大榜:是啊,使用volatile修饰某个变量后,等于告诉JVM,这个变量不稳定,每次取的时候,都要去主内存中获取。

而且,synchronized关键字不仅可以保证可见性,同时还保证了互斥性。

小汪:我记得synchronized修饰静态方法,锁对象是当前类;修饰实例方法,锁对象是当前实例对象。

大榜:你得记忆很好。上面讨论的synchronized关键字是悲观锁的一种,对于悲观锁来说,它总是认为每次访问共享资源时会发生冲突,所以必须每次对数据操作上锁。若加锁不正确,可能会造成死锁。

对于乐观锁来说,又称为无锁,乐观锁总是假设对共享资源的访问没有冲突,线程可以不停地执行,无需加锁也无需等待,一旦多个线程发生冲突,乐观锁使用CAS算法来保证线程执行的安全性。

小汪:CAS算法,我听说过,中文叫做比较和交换,其逻辑就是拿着旧值和期望值进行比较,如果相等则说明没有其它线程改过,更新旧值;如果不相等,则当前线程放弃更新,什么也不做。

大榜:是滴了,CAS算法得逻辑就是这样。当多个线程同时使用CAS操作一个共享变量时,只有一个线程会胜出,并成功更新,其余线程会失败。需要说明的是,失败的线程并不会被挂起,允许再次尝试。

小汪:CAS算法实现原子操作,我记得存在ABA问题?

大榜:被你发现了,ABA问题就是一个值原来是A,变成了B,又变回了A,这个时候使用CAS是无法检查出变化的,但实际上该值已被更新了两次。ABA问题的解决思路是在变量前面追加版本号或者时间戳。接下来,我们讨论下AQS,AQS是AbstractQueuedSynchronizer的简称,中文名叫做抽象队列同步器,我们当前只需要知道它是构建JDK并发包的基础组件,像ReentrantLock、CountDownLatch、FutureTak等都是基于AQS实现的。

小汪:嗯嗯,我下去看看AQS,也不指望一遍就能搞懂了。

4、使用Java多线程-JDK工具篇

大榜:哈哈哈,是啊,我现在也没完全搞懂AQS,只知道AQS是什么以及应用场景如何。接下来,我们一起看看JDK并发包中的类库,并发包可以简化我们开发出正确的多线程程序。

小汪:并发包就是java.util.concurrent包,是吧,我记得是JDK 5引入的,里面的很多轮子都很不错。

大榜:是啊。首先我们学习内置锁synchronized与显示锁Lock的区别和联系;接着,我们看看基于CAS算法的原子类,如AtomicInteger;然后,我们学习线程本地变量ThreadLocal;进一步,我们学习基于生产者-消费者模型的阻塞队列;再然后介绍线程池;紧接着,我们学习并发容器;最后,我们一起讨论线程之间的协作工具类。

小汪:JDK提供的并发包中的类 真不少啊,应该是对应不同的使用场景把?

大榜:是的了,每个类都有对应的使用场景。比如说,ThreadLocal保证了每个线程都有一个对应的状态变量,不在线程之间共享该状态变量,这样就不会有线程安全问题。其使用场景如下:由于JDBC的连接对象Connection不一定是线程安全的,所以,当多线程应用程序在没有协同的情况下使用全局变量时,就不是线程安全的。

为了防止多个线程同时操作同一个Connection对象,我们使用ThreadLocal来表示:也就是将JDBC连接对象Connection保存到ThreadLocal对象中,这样每个线程都会拥有属于自己的连接,使用ThreadLocal来维持线程封闭性,保证了线程安全。

小汪:阻塞队列是生产者-消费者模型的实现,我记得有BlockingQueue、ArrayBlockQueue、LinkedBlockingQueue、PriorityBlockingQueue、同步的SynchronousQueue。

大榜:阻塞队列的应用场景很广,线程池的底层用到了阻塞队列,线程池将任务提交与任务执行解耦开来,任务提交相当于生产者,任务执行相当于消费者。 线程池分为:JDK提供的线程池、自定义线程池、定时任务线程池。

对于JDK提供的线程池,有造成资源耗尽的风险,一般不建议在生产环境中使用;自定义线程池一般是使用ThreadPoolExecutor构造函数来实现的;定时任务线程池是基于ScheduledThreadPoolExecutor构造函数实现的。

小汪:线程池中有一些核心参数,我记得面试经常问,像核心线程数、最大线程数,任务队列、拒绝策略,我下去补补功课。并发容器有哪些呢?

大榜:并发容器有下面4类,如下所示:

1)写时复制的List和Set

CopyOnWriteArrayList、CopyOnWriteArraySet,应用在读多写少的场景。

2)并发队列

无锁非阻塞并发队列:ConcurrentLinkedQueue、ConcurrentLinkedDeque。

3)并发Map

HashMap对应的并发版本:ConcurrentHashMap

4)基于跳表的Map和Set

TreeMap对应的并发版本:ConcurrentSkipListMap

TreeSet对应的并发版本:ConcurrentSkipListSet

小汪:线程之间协作的工具类有哪些,使用场景如何?

大榜:线程协作工具类大致分为下面5种:

1)synchronized/wait/notify

2)Lock/Condition

3)CountDownLatch

4)CyclicBarrier

5)阻塞队列

使用CountDownLatch 实现裁判员-运动员起跑,裁判作为主线程,多个运动员相当于多个子线程,作为一般来说,对于CountDownLatch类,是在主线程中初始化计数器,然后进入等待状态;子线程完成任务后进行倒计时减1;当计数器减为0零时,主线程结束等待进入后续流程处理。

而对于CyclicBarrier类,是在主线程中初始化栅栏值,然后子线程中调用await方法,当每个子线程都到达await方法后,每个线程才去执行await后面的逻辑。这样可以实现所有子线程同时运行,模拟高并发场景。

5、多线程的踩坑点

小汪:你上面提到了多线程会带来3个风险,其中提高了如果多线程程序同步错误,可能会造成死锁,那多线程的死锁是如何产生的?

大榜:我们举个栗子说明把,比如有两个线程1、2,线程1持有锁A,在等待锁B;而线程2持有锁B,在等待锁A,此时线程1和2陷入了互相等待状态,最后谁都没法执行下去。这就是最经典的死锁场景。

小汪:那如何避免死锁呢?

大榜:Java中避免死锁,主要有如下方式:

1)定义锁的顺序,并在整个应用程序中都按照这个顺序来获取锁。比如上面的死锁场景中,我们可以约定先申请锁lockA,再申请lockB,这样就可以避免死锁。

2)使用显示锁中的tryLock方法或者是带有时间限制的获取锁方法,来避免死锁。

3)使用无锁的CAS算法,来避免死锁。

6、Java多线程案例

6.1、Web服务器模型的改进优化之路

小汪:榜哥呀,Java多线程,有没有好的案例,你光讲理论,我还是云里雾里啊。

大榜:当然有了,我可是学了一个月的Java多线程啊,哈哈哈。第一个案例,是Web服务器模型的改进优化,我们一起来体会下多线程的优点。首先,我们实现一个串行的Web服务器,代码是这样:

package net.jcip.examples;
​
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
​
/**
 * SingleThreadWebServer:单个线程的Web服务器
 *
 * 串行的Web服务器:每次只能处理一个请求,当服务器正在处理请求1时,新到来的连接请求必须等待请求1处理完成,也就是说只能串行地执行任务。
 * 显然,串行处理机制通常都无法提供高吞吐率或快速响应性。
 */public class SingleThreadWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
​
    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

你看,串行的Web服务器中:每次只能处理一个请求,当服务器正在处理请求1时,新到来的连接请求必须等待请求1处理完成,也就是说只能串行地执行任务。

小汪:是啊。串行的Web服务器只能串行地执行任务,无法提供高吞吐率或快速响应性。

大榜:所以说,为了提高系统的快速响应性,我们需要在Web服务器中为每个请求,创建一个新的线程,这样对于多个请求,都是一个新线程处理,不再是串行地执行任务了。代码是这样的:

package net.jcip.examples;
​
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
​
/**
 * ThreadPerTaskWebServer:在Web服务器中为每个请求,创建一个新的线程。
 * 其优点是:可以同时服务多个请求,提高响应性。
 * 但也存在缺点:即无限制的创建线程。
 *
 * 在一定范围内,增加线程可以提高系统的吞吐率;但如果超过了这个范围,再创建更多的线程只会降低程序的执行效率。
 * 如果可运行的线程数量多于可用处理器的数量,那么有些线程将闲置。大量空闲的线程会占用许多内存,给垃圾收集器带来压力。
 *  而且,如果我们已经拥有足够多的线程使所有CPU 保持忙碌状态,那么再创建更多的线程反而会降低性能。
 */
public class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                public void run() {
                    handleRequest(connection);
                }
            };
            new Thread(task).start();
        }
    }
​
    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}
​

你看,上面的代码实现了为每个请求,创建一个新的线程。其优点是:可以同时服务多个请求,提高响应性;但也存在缺点,即毫无限制的创建线程,如果请求数量巨大,最终可能导致资源耗尽。

小汪:是啊,如果请求数量大,而且每个请求耗时长,会一直占用系统资源,最终导致资源耗尽,感觉你这个基于每个请求的Web服务器模型很不稳定啊。

大榜:哈哈哈,是的。所以,接下来我们使用池化技术提高Web服务器的稳定性,即实现基于线程池的Web服务器模型,代码是这样的:

package net.jcip.examples;
​
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.*;
​
/**
 * TaskExecutionWebServer:基于线程池的Web服务器
 * 从“为每个任务分配一个线程”策略,转变为基于线程池地策略,将对应用程序的稳定性产生重大影响:
 *  1)Web服务器不会在高负载情况下失败:因为服务器不会创建数千个线程来争夺有限的CPU和内存资源。
 *  2)Executor,可以实现各种调优、管理、监视、记录日志、错误报告等可观测功能。
 *
 *
 * 任务是一组逻辑工作单元,而线程则是使任务异步执行的机制。
 * 把所有任务放在单个线程中串行执行,以及将每个任务放在各自的线程中执行,这2种方式都存在一些问题:
 *      串行执行的问题在于其糟糕的响应性和吞吐量;而“为每个请求创建一个线程”的问题在于资源管理的复杂性。
 *
 * Executor接口:提供了一种标准的方法,将任务的提交过程、任务执行过程解耦开来,用Runnable来表示任务。
 *  Executor是基于生产者-消费者模式,提交任务相当于生产者,执行任务相当于消费者。
 *
 *  线程池与工作队列是密切相关的,工作队列中保存了所有等待执行的任务,
 *  线程池中的工作线程,反复地从工作队列中取出任务并执行。
 */
public class TaskExecutionWebServer {
    private static final int NTHREADS = 100;
​
    private static final Executor exec = Executors.newFixedThreadPool(NTHREADS);
​
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                public void run() {
                    handleRequest(connection);
                }
            };
            // 调用exec.execute方法,用来提交任务
            exec.execute(task);
        }
    }
​
    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}
​

你看,我们从“为每个任务分配一个线程”策略,转变为基于线程池的策略后,对应用程序的稳定性产生重大影响,即Web服务器不会在高负载情况下失败,因为服务器不会创建数千个线程来争夺有限的CPU和内存资源。

小汪:我体会到了,感觉使用线程池技术就是香啊。

大榜:是啊。总的来说,把所有任务放在单个线程中串行执行,以及将每个任务放在各自的线程中执行,这2种方式都存在问题:串行执行的问题在于其糟糕的响应性和吞吐量;而“为每个请求创建一个线程”的问题在于资源管理的复杂性。所以,最后我们采用基于线程池的Web服务器,因为基于线程池的策略,可以很好地进行资源管理,提高程序的稳定性。

6.2、自定义缓存的改进优化之路

小汪:Web服务器模型的改进优化案例中,一步一步地进行优化改进,最终实现了基于线程池的Web服务器模型。还有其它学习案例吗?

大榜:第二个是自定义缓存的改进优化案例,可能有点难,需要好好体会。假设老板给我们一个需求,对计算的结果进行缓存,避免重复计算。最容易想到的实现方案是,我们对获取计算结果加一把锁,这种方案能够保证代码的正确性,保证线程安全。编写多线程程序时,我有一个很深的体会,就是我们首先要保证正确性,然后在程序正确的基础上,才去做改进优化。

小汪:是的啊,如果多线程的程序是不正确的,那么我们做再多的优化改进,也是徒劳无功。

大榜:加一把锁的方案,其实现代码是这样的:

package net.jcip.examples;
​
import java.math.BigInteger;
import java.util.*;
​
import lombok.extern.slf4j.Slf4j;
import net.jcip.annotations.*;
​
/**
 * 第一种:使用HashMap和同步机制来初始化缓存。
 *
 * Memoizer1 为包装类。包装类的作用:使用HashMap、synchronized提供缓存功能。
 */
@Slf4j
public class Memoizer1 <A, V> implements Computable<A, V> {
​
    @GuardedBy("this") private final Map<A, V> cache = new HashMap<A, V>();
​
    private final Computable<A, V> computable;
​
    public Memoizer1(Computable<A, V> c) {
        this.computable = c;
    }
​
    /**
     * 使用synchronized关键字,对整个compute方法进行同步。这种方法能够确保线程安全,但会带来一个明显的可伸缩性问题,如下所示:每次只有一个线程能够进入执行compute方法。
     *    如果有一个线程正在计算结果,那其他调用compute的线程可能会被阻塞很长时间。
     *    如果有多个线程 仍在排队等待 还没有计算出来的结果,那么compute的计算时间可能比没有缓存操作的计算时间还要更长。这种情况下,缓存反而降低了性能。
     *
     * @param arg
     * @return
     * @throws InterruptedException
     */
    public synchronized V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
        if (result == null) {
            result = computable.compute(arg);
            cache.put(arg, result);
            log.info("该值 {} 在缓存中不存在,放入缓存:{}", result, cache);
            return result;
        } else {
            log.info("命中缓存:" + cache + "  " + result);
            return result;
        }
​
    }
}
​
/**
 * 计算接口
 * @param <A>
 * @param <V>
 */
interface Computable <A, V> {
    V compute(A arg) throws InterruptedException;
}
​
/**
 * 实际参与计算的类
 */
class ExpensiveFunction implements Computable<String, BigInteger> {
    public BigInteger compute(String arg) {
        // after deep thought...  此处计算会花很长时间
        return new BigInteger(arg);
    }
}
​
class CacheTest {
    public static void main(String[] args) throws InterruptedException {
        ExpensiveFunction expensiveFunction = new ExpensiveFunction();
​
        // Memoizer1为ExpensiveFunction的包装类,包装类的作用:使用HashMap、synchronized提供缓存功能。
//        final Memoizer1 memoizer1 = new Memoizer1(expensiveFunction);
//          System.out.println("首先获取值:" + memoizer1.compute("456"));//        final Memoizer1 memoizer = new Memoizer1(expensiveFunction);
//        final Memoizer2 memoizer = new Memoizer2(expensiveFunction);
//        final Memoizer3 memoizer = new Memoizer3(expensiveFunction);
        final Memoizer memoizer = new Memoizer(expensiveFunction);
​
        new Thread(() -> {
            try {
                System.out.println("第一个线程,获取值123:" + memoizer.compute("123"));
                System.out.println("第一个线程,获取值456:" + memoizer.compute("456"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
​
        }).start();
​
        // 此处休眠的作用:让第二个线程滞后启动
        Thread.sleep(100);
​
        new Thread(() -> {
            try {
                System.out.println("第二个线程,获取值123:" + memoizer.compute("123"));
                System.out.println("第二个线程,获取值456:" + memoizer.compute("456"));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
​
        }).start();
​
    }
}

上面的代码中,使用synchronized关键字,对整个compute方法进行同步。这种方法能够确保线程安全,但会带来一个明显的可伸缩性问题,如下所示:每次只有一个线程能够进入执行compute方法。 1) 如果有一个线程正在计算结果,那其他调用compute的线程可能会被阻塞很长时间。 2)如果有多个线程 仍在排队等待 还没有计算出来的结果,那么compute的计算时间可能比没有缓存操作的计算时间还要更长。这种情况下,缓存反而降低了性能。

小汪:当多个线程竞争激烈的情况,缓存可能会降低性能,这不是我们期望的结果呀。那下面该如何改进呢?

大榜:我们可以使用JDK并发包中的ConcurrentHashMap,也就是说使用ConcurrentHashMap替代HashMap,来改进Memoizer1中糟糕的并发行为。因为ConcurrentHashMap是线程安全的,因此在访问底层Map时,就不需要进行同步,所以有更好的并发行为。代码如下:

package net.jcip.examples;
​
import lombok.extern.slf4j.Slf4j;
​
import java.util.*;
import java.util.concurrent.*;
​
/**
 * 第二种:Memoizer2。
 * 使用ConcurrentHashMap替代HashMap,来改进Memoizer1中糟糕的并发行为。因为ConcurrentHashMap是线程安全的,因此在访问底层Map时,就不需要进行同步,所以有更好的并发行为。
 *  但是,它作为缓存时,仍然存在一些不足:当2个线程同时调用compute时会存在一个漏洞,可能会导致计算得到相同的值。具体如下:
 *     如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么可能会重复这个计算。
 *     所以,我们希望通过某种方法来表达"线程X正在计算 f(27)"这种情况,这样当另一个线程查找 f(27)时,该线程能够知道最高效的方法是等待线程X计算结束,然后再去查询缓存"f(27)"的结果是多少?
 */
@Slf4j
public class Memoizer2 <A, V> implements Computable<A, V> {
​
    private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
​
    private final Computable<A, V> c;
​
    public Memoizer2(Computable<A, V> c) {
        this.c = c;
    }
​
    public V compute(A arg) throws InterruptedException {
        V result = cache.get(arg);
​
        if (result == null) {
            result = c.compute(arg);
            cache.put(arg, result);
            log.info("该值 {} 在缓存中不存在,放入缓存:{}", result, cache);
        } else {
            log.info("命中缓存:" + cache + "  " + result);
        }
        return result;
    }
}
​

但是,ConcurrentHashMap作为缓存时,仍然存在一些不足:当2个线程同时调用compute时会存在一个漏洞,可能会导致计算得到相同的值。具体如下:

  • 如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么可能会重复这个计算。
  • 所以,我们希望通过某种方法来表达"线程X正在计算 f(27)"这种情况,这样当另一个线程查找 f(27)时,该线程能够知道最高效的方法就是等待线程X计算结束,然后再去查询缓存"f(27)"的结果是多少?

小汪:我懂了,我们可以使用Future来存储计算结果。

大榜:小伙子基础不错啊。使用Future来存储计算结果,其实现代码是下面这样的:

package net.jcip.examples;
​
import lombok.extern.slf4j.Slf4j;
​
import java.util.*;
import java.util.concurrent.*;
​
/**
 * 第三种:Memoizer3
 * 定义ConcurrentHashMap<A, Future<V>>,代替原来的ConcurrentHashMap<A, V>。Memoizer3 首先检查某个相应的计算是否已经开始,
 *  若任务没有启动,则创建一个FutureTask,并注册到Map中,然后启动计算;如果已经启动,那么等待计算的结果出来。
 *   Memoizer2中是判断某个计算是否已经完成,当2个线程同时调用compute时会存在一个漏洞,可能会导致计算得到相同的值。
 *   Memoizer3中,若结果已经计算出来,则立即返回;若计算结果还未出来,那么新到的线程将一直等待这个结果被计算出来,也就是说Memoizer3中使用 Future存储了未来计算的结果。
 */
@Slf4j
public class Memoizer3 <A, V> implements Computable<A, V> {
​
    private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
​
    private final Computable<A, V> c;
​
    public Memoizer3(Computable<A, V> c) {
        this.c = c;
    }
​
    public V compute(final A arg) throws InterruptedException {
        log.info("缓存:" + cache);
        Future<V> f = cache.get(arg);
        // 若任务没有启动,则创建一个FutureTask,并注册到Map中,然后启动计算;如果已经启动,那么等待计算的结果出来,结果可能很快会得到,也可能还在运算过程中。
        if (f == null) {
            log.info("该future值:{} 正在计算中,入参:{}", f, arg);
            Callable<V> eval = new Callable<V>() {
                public V call() throws InterruptedException {
                    return c.compute(arg);
                }
            };
            FutureTask<V> ft = new FutureTask<V>(eval);
            f = ft;
            cache.put(arg, ft);
            // 在这里将调用c.compute方法
            ft.run();
        }
        try {
            // 返回结果
            return f.get(5, TimeUnit.SECONDS);
        } catch (Exception e) {
            log.error("异常:", e);
            throw LaunderThrowable.launderThrowable(e.getCause());
        }
    }
}

我们定义了ConcurrentHashMap<A, Future>,代替原来的ConcurrentHashMap<A, V>。Memoizer3 首先检查某个相应的计算是否已经开始:

  • 若任务没有启动,则创建一个FutureTask,并注册到Map中,然后启动计算;如果已经启动,那么等待计算的结果出来。
  • Memoizer3中,若结果已经计算出来,则立即返回若计算结果还未出来,那么新到的线程将一直等待这个结果被计算出来,也就是说Memoizer3中使用 Future存储了未来计算的结果。但Memoizer2中是判断某个计算是否已经完成,当2个线程同时调用compute时会存在一个漏洞,可能会导致计算得到相同的值。

小汪:自定义缓存的改进优化案例,干货满满啊。

先使用synchronized加锁来保证访问计算结果的线程安全性;

接着,使用ConcurrentHashMap替代HashMap,来改进Memoizer1中糟糕的并发行为,但如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么可能会重复这个计算。

最后,定义ConcurrentHashMap<A, Future> 代替原来的ConcurrentHashMap<A, V>,Memoizer3 首先检查某个相应的计算是否已经开始,使用 Future存储了未来计算的结果,其他线程会等待线程X计算结束,这样就不会造成重复计算。

7、总结

通过小汪和大榜的对话,我们一起讨论了多线程的概念和使用场景,简单地介绍了Java多线程的原理、JDK并发包中各个类的使用场景,最后以Web服务器模型的改进优化、自定义缓存的改进优化这2个案例作为结束。想要编写出正确的多线程代码,我们还有许多坑要踩,与大家一起再接再厉!

8、参考内容

1)《Java并发编程实战》Brian Goetz

2)《深入浅出Java多线程》-RedSpider社区

3)《Java编程的逻辑》-马俊昌,第五部分(并发)