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内存模型,如下图:
从图中可以看出:
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
3)《Java编程的逻辑》-马俊昌,第五部分(并发)