推荐的线程数量计算公式有两种:
公式一:线程数量=(线程总时间/瓶颈资源时间)X 瓶颈资源的线程并行数
公式二:QPS=1000/线程总时间 X 线程数
由于用户场景的不同,对于一些复杂的系统,实际上很难计算出最优的线程配置,只能根据测试数据和用户场景,结合公式给出一个相对合理的范围,然后对范围内的数据进行性能测试,选择相对最优值。
使用Condition
使用 ReentrantLock 比直接使用 synchronized 更安全,可以替代 synchronized 进行线程同步。
但是,synchronized 可以配合 wait 和 notify 实现线程在条件不满足时等待,条件满足时唤醒,用 ReentrantLock我们怎么编写 wait 和 notify 的功能呢?
答案是使用 Condition 对象来实现 wait 和 notify 的功能。
我们仍然以 TaskQueue 为例,把前面用 synchronized 实现的功能通过 ReentrantLock 和 Condition 来实现:
class TaskQueue {
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
private Queue<String> queue = new LinkedList<>();
public void addTask(String s) {
lock.lock();
try {
queue.add(s);
condition.signalAll();
} finally {
lock.unlock();
}
}
public String getTask() {
lock.lock();
try {
while (queue.isEmpty()) {
condition.await();
}
return queue.remove();
} finally {
lock.unlock();
}
}
}
可见,使用 Condition 时,引用的 Condition 对象必须从 Lock 实例的 newCondition() 返回,这样才能获得一个绑定了 Lock 实例的 Condition 实例。
Condition 提供的 await()、signal()、signalAll() 原理和 synchronized 锁对象的 wait()、notify()、notifyAll() 是一致的,并且其行为也是一样的:
-
await() 会释放当前锁,进入等待状态;
-
signal() 会唤醒某个等待线程;
-
signalAll() 会唤醒所有等待线程;
-
唤醒线程从 await() 返回后需要重新获得锁。
此外,和 tryLock() 类似,await() 可以在等待指定时间后,如果还没有被其他线程通过 signal() 或 signalAll() 唤醒,可以自己醒来:
if (condition.await(1, TimeUnit.SECOND)) {
// 被其他线程唤醒
} else {
// 指定时间内没有被其他线程唤醒
}
可见,使用 Condition 配合 Lock ,我们可以实现更灵活的线程同步。
小结:
- Condition 可以替代 wait 和 notify;
- Condition 对象必须从 Lock 对象获取。
使用StampedLock
ReadWriteLock 可以解决多线程同时读,但只有一个线程能写的问题。
如果我们深入分析 ReadWriteLock,会发现它有个潜在的问题:如果有线程正在读,写线程需要等待读线程释放锁后才能获取写锁,即读的过程中不允许写,这是一种悲观的读锁。
要进一步提升并发执行效率,Java 8引入了新的读写锁:StampedLock。
和 ReadWriteLock 相比,写入的加锁是完全一样的,不同的是读取。注意到首先我们通过 tryOptimisticRead() 获取一个乐观读锁,并返回版本号。接着进行读取,读取完成后,我们通过 validate() 去验证版本号,如果在读取过程中没有写入,版本号不变,验证成功,我们就可以放心地继续后续操作。如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,我们再通过获取悲观读锁再次读取。由于写入的概率不高,程序在绝大部分情况下可以通过乐观读锁获取数据,极少数情况下使用悲观读锁获取数据。
小结:
- StampedLock提供了乐观读锁,可取代ReadWriteLock以进一步提升并发性能;
- StampedLock是不可重入锁。
使用ForkJoin
ForkJoin是一种基于“分治”的算法:通过分解任务,并行执行,最后合并结果得到最终结果。
ForkJoinPool线程池可以把一个大任务分拆成小任务并行执行,任务类必须继承自RecursiveTask或RecursiveAction。
使用Fork/Join模式可以进行并行计算以提高效率。
使用CompletableFuture
使用 Future 获得异步执行结果时,要么调用阻塞方法 get() ,要么轮询看i sDone() 是否为 true,这两种方法都不是很好,因为主线程也会被迫等待。
从Java 8开始引入了 CompletableFuture ,它针对 Future 做了改进,可以传入回调对象,当异步任务完成或者发生异常时,自动调用回调对象的回调方法。
CompletableFuture可以指定异步处理流程:
- thenAccept()处理正常结果;
- exceptional()处理异常结果;
- thenApplyAsync()用于串行化另一个CompletableFuture;
- anyOf()和allOf()用于并行化多个CompletableFuture。
注意CompletableFuture的命名规则:
- xxx():表示该方法将继续在已有的线程中执行;
- xxxAsync():表示将异步在线程池中执行。
并发编程主要解决两个关键问题
- 线程之间如何通信
- 线程之间如何同步
多线程使用场景
消息队列
一笔订单的创建,它包括插入订单数据、生成订单快照、发送邮件通知卖家和记录货品销售数量等。用户从单击“订购”按钮开始,就要等待这些操作全部完成才能看到订购成功的结果。为加快业务操作完成,可使用多线程技术,即将数据一致性不强的操作派发给其他线程处理(例如消息队列),如生成订单快照、发送邮件等。这样做的好处是响应用户请求的线程能够尽可能快地处理完成,缩短响应时间,提升用户体验。
多线程应用测试场景
web服务器处理连接与请求:
测试场景是5000次请求,分10个线程并发执行,测试内容主要考察响应时间(越小越好)和每秒查询的数量(越高越好),测试结果显示:随着线程池中线程数量的增加,吞吐量不断增大,响应时间不断变小,线程池的作用非常明显。
什么是阻塞?
阻塞的本质是将进程挂起,不再参与进程调度。而挂起的本质是将进程的 state 赋值为非RUNNABLE,这样调度机制的代码中就不会把它作为下一个获得CPU运行机会的可选项。
ThreadLocal作用及用途
ThreadLocal的作用是提供线程内的局部变量,在多线程环境下访问时能保证各个线程内的ThreadLocal变量各自独立。也就是说,每个线程的ThreadLocal变量是自己专用的,其他线程是访问不到的。ThreadLocal最常用于以下这个场景:多线程环境下存在对非线程安全对象的并发访问,而且该对象不需要在线程间共享,但是我们不想加锁,这时候可以使用ThreadLocal来使得每个线程都持有一个该对象的副本。
ThreadLocal特性
ThreadLocal和Synchronized都是为了解决多线程中相同变量的访问冲突问题,不同的点是:
-
Synchronized是通过线程等待,牺牲时间来解决访问冲突。
-
ThreadLocal是通过每个线程单独一份存储空间,牺牲空间来解决冲突,并且相比于Synchronized,ThreadLocal具有线程隔离的效果,只有在线程内才能获取到对应的值,线程外则不能访问到想要的值。
正因为ThreadLocal的线程隔离特性,使他的应用场景相对来说更为特殊一些。当某些数据是以线程为作用域并且不同线程具有不同的数据副本的时候,就可以考虑采用ThreadLocal。
java创建线程池的4种方式
Java通过Executors提供四种线程池,分别为:
- newCachedThreadPool创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
- newFixedThreadPool 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
- newScheduledThreadPool 创建一个定长线程池,支持定时及周期性任务执行。
- newSingleThreadExecutor 创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO, LIFO, 优先级)执行。