简介:
并发编程的目的是为了让程序运行的更快,但是,并不是启动更多的线程就能让程序最大限度的并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,会面临非常多的挑战,比如上下文切换的问题、死锁问题,以及受限于硬件和软件的资源限制问题,本篇文章介绍几种并发编程的挑战及解决方案,文章总结至《Java并发编程的艺术》
一、上下文切换
即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程执行的时间,因为时间片非常短,所有CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。
CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一次任务的状态,以便于下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
这就像我们同事读两本书,当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本英文技术书。这样的切换时会影响读书效率的,同样的道理上下文的切换也会影响多线程的执行速度。
1.1 多线程一定快吗
下面的代码演示串行和并发执行并累加操作的时间,分析并发执行一定比串行执行快么?
package com.lizba.p1; /** * <p> * 测试并发执行和串行的速度 * </p> * * @Author: Liziba * @Date: 2021/6/2 23:40 */ public class ConcurrencyTest { /** 执行次数 */ private static final long count = 10000; public static void main(String[] args) throws InterruptedException { concurrency(); serial(); } /** * 并发执行 * @throws InterruptedException */ private static void concurrency() throws InterruptedException { long start = System.currentTimeMillis(); Thread thread = new Thread(new Runnable() { public void run() { int a = 0; for (long i = 0; i < count; i++) { a +=5; } } }); thread.start(); int b = 0; for (long i = 0; i < count; i++) { b--; } thread.join(); long time = System.currentTimeMillis() - start; System.out.println("concurrency :" + time + "ms, b=" + b); } /** * 串行执行 */ private static void serial() { long start = System.currentTimeMillis(); int a = 0; for (long i = 0; i < count; i++) { a += 5; } int b = 0; for (long i = 0; i < count; i++) { b--; } long time = System.currentTimeMillis() - start; System.out.println("serial :" + time + "ms, b=" + b); } }
从上表可以看出,当并发执行累计操作低于百万次时,速度会比串行执行累加操作要慢。为什么在这种情况下并发执行比串行执行要慢呢?这是因为创建线程和上下文切换的时间开销要远远大于简单计算的时间开销。
1.2 测试上下文切换次数和时长
测试工具:
使用Lmbench3可以测量上下文切换的时长
使用vmstat可以测量上下文切换的次数
vmstat参数的含义:
CS(Content Switch)表示上下文切换的次数,从上面的可以看出上下文每秒钟切换1000多次。
1.3 如何减少上下文切换
减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。
无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的id按照Hash算法取模分段,不同的线程处理不同段的数据。
CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程处于等待状态。
协程。在单线程里实现多任务调度,并在单线程里维持多个任务见的切换。
1.4 减少上下文切换实战
这个例子简单说明如何来减少线程池中大量WAITING线程,来减少上下文切换次数。(本文在Windows环境dump测试)
写一个模拟出现WAITING状态的代码:
package com.lizba.p1; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * <p> * 线程池Dump测试 -- 代码只是示例 * </p> * * @Author: Liziba * @Date: 2021/6/4 23:26 */ public class ThreadPoolDumpTest { public static void main(String[] args) { // 创建固定大小的线程池 ExecutorService fixedThreadPool = Executors.newFixedThreadPool(300); // 初始化线程池中的线程 for (int i = 0; i < 300; i++) { fixedThreadPool.execute(getThread(i)); } while (true) { try { Thread.sleep(5000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("测试!"); } } /** * 创建线程 * @param i * @return */ private static Runnable getThread(final int i) { return new Runnable() { public void run() { try { Thread.sleep(50); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(i); } }; } }
- 用jstack命令dump线程信息,可以看当前运行的Java程序的pid,查看当前进程号里的线程在做什么。打开dump文件查看处于(onobjectmonitor)阻塞的线程在做什么。
发现有300个线程处于WAITING状态
"pool-1-thread-300" #311 prio=5 os_prio=0 tid=0x000000002fe46800 nid=0x4880 waiting on condition [0x0000000033cfe000] java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) - parking to wait for <0x000000077b098178> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039) at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442) at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None
在上面的简单案例中WAITING线程减少了,系统上下文切换的次数就会减少,因为每一次从WAITING到RUNNABLE都会进行一次上下文的切换。在实际开发中,我们并不会做这么看似低级的操作,但是样例却能给我们代理线程池优化和程序线程优化各方面的解决问题的思路。
二、死锁
锁是一个非常有用的工具,运用的场景非常多,因为它使用起来非常简单,而且易于理解。但同时它会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。
2.1 死锁示例
下面演示一段引起死锁的代码,使得线程t1和线程t2互相等待对方释放锁。
package com.lizba.p1; /** * <p> * 死锁示例代码 * </p> * * @Author: Liziba * @Date: 2021/6/5 0:37 */ public class DeadLockDemo { private static final String A = "A"; private static final String B = "B"; /** * t1\t2互相持有锁 */ private void deadLock() { Thread t1 = new Thread(new Runnable() { public void run() { // 持有锁A synchronized (A) { try { Thread.currentThread().sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } // 持有锁B synchronized (B) { System.out.println("hold Lock B"); } } } }); Thread t2 = new Thread(new Runnable() { public void run() { // 持有锁B synchronized (B) { try { Thread.currentThread().sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } // 持有锁A synchronized (A) { System.out.println("hold Lock A"); } } } }); t1.start(); t2.start(); } public static void main(String[] args) { new DeadLockDemo().deadLock(); } }
这段代码演示的是简单的死锁场景,在现实中大家都不会写出这样的代码。但是,在一些更为复杂的场景中,你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况并没有释放锁(比如死循环)。又或者t1拿到一个数据库锁,释放锁的时候抛出了异常,没有释放掉。
现实中,一旦出现了死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程查看到底是哪个线程出现了问题,我们分析如下Dump出的线程信息:
"Thread-1" #13 prio=5 os_prio=0 tid=0x000000001e011000 nid=0x5318 waiting for monitor entry [0x000000001fcef000] java.lang.Thread.State: BLOCKED (on object monitor) at com.lizba.p1.DeadLockDemo$2.run(DeadLockDemo.java:50) - waiting to lock <0x000000076b042000> (a java.lang.String) - locked <0x000000076b042030> (a java.lang.String) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None "Thread-0" #12 prio=5 os_prio=0 tid=0x000000001e00f800 nid=0x4b38 waiting for monitor entry [0x000000001fbef000] java.lang.Thread.State: BLOCKED (on object monitor) at com.lizba.p1.DeadLockDemo$1.run(DeadLockDemo.java:33) - waiting to lock <0x000000076b042030> (a java.lang.String) - locked <0x000000076b042000> (a java.lang.String) at java.lang.Thread.run(Thread.java:748) Locked ownable synchronizers: - None
从上可以看出第33行和第50行引发了死锁。
2.2 避免产生死锁
避免一个线程同时获取多个锁。
避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
尝试使用定时锁,使用lock.tryLock(timeout)来替代使用内部锁机制。
对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
三、资源限制
3.1 什么是资源限制
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。例如,服务器的带宽只有2MB/s,某个资源的下载速度是1MB/s,系统启动10个线程下载资源,下载速度不会变成10MB/s,所以在并发编程时,要考虑这些资源的限制。
硬件资源限制有带宽的上传/下载速度、硬盘读写速度和CPU处理速度。
软件资源的限制有数据库的连接和socket连接数等。
3.2 资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行,但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这样程序不仅不会加快,反而会更慢,因为增加上下文切换和资源调度的时间。
3.3 如何解决资源限制的问题
对于硬件资源的限制,可以考虑使用集群并行执行程序
对应软件资源的限制,可以考虑使用资源池将资源复用
3.4 在资源限制情况下并发编程
如何在资源限制的情况下,让程序执行的更加快呢?方法就是,根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源-宽带和硬盘的读写速度。有数据库操作时,涉及数据库连接,如果SQL执行非常快,而线程的数量比数据量连接数大很多,则某些线程会被阻塞,等待数据库连接。