《Java并发编程的艺术》第一章

第1章并发编程的挑战

并发编程的目的是为了让程序运行的更快,但是并不是启动更多的线程,就能让程序最大限度的并发执行。在进行并发编程时,如果希望通过多线程执行任务让程序运行的更快,会面临非常多的挑战,比如上下文切换的问题,死锁的问题,以及受限于硬件和软件的资源限制问题,本章会介绍几种并发编程的挑战,以及解决方案。

1.1     上下文切换

即使是单核处理器也支持多线程执行代码,CPU通过给每个线程分配CPU时间片来实现这个机制。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下个任务,但是在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务的保存到再加载的过程就是一次上下文切换

就像我们同时在读两本书,比如当我们在读一本英文的技术书时,发现某个单词不认识,于是便打开中英文字典,但是在放下英文技术书之前,大脑必需首先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书,这样的切换是会影响读书效率的,同样上下文切换也会影响到多线程的执行速度。

1.1.1    多线程一定快吗?

下面的代码演示串行和并发执行累加操作的时间,请思考下面的代码并发执行一定比串行执行快些吗?

01 package chapter01;
02  
03 /**
04  * 并发和单线程执行测试
05  * @author tengfei.fangtf
06  * @version $Id: ConcurrencyTest.java, v 0.1 2014-7-18 下午10:03:31 tengfei.fangtf Exp $
07  */
08 public class ConcurrencyTest {
09  
10     /** 执行次数 */
11     private static final long count = 10000l;
12  
13     public static void main(String[] args) throws InterruptedException {
14         //并发计算
15         concurrency();
16         //单线程计算
17         serial();
18     }
19  
20     private static void concurrency() throws InterruptedException {
21         long start = System.currentTimeMillis();
22         Thread thread = new Thread(new Runnable() {
23             @Override
24             public void run() {
25                 int a = 0;
26                 for (long i = 0; i < count; i++) {
27                     a += 5;
28                 }
29                 System.out.println(a);
30             }
31         });
32         thread.start();
33         int b = 0;
34         for (long i = 0; i < count; i++) {
35             b--;
36         }
37         long time = System.currentTimeMillis() - start;
38         thread.join();
39         System.out.println("concurrency :" + time + "ms,b=" + b);
40     }
41  
42     private static void serial() {
43         long start = System.currentTimeMillis();
44         int a = 0;
45         for (long i = 0; i < count; i++) {
46             a += 5;
47         }
48         int b = 0;
49         for (long i = 0; i < count; i++) {
50             b--;
51         }
52         long time = System.currentTimeMillis() - start;
53         System.out.println("serial:" + time + "ms,b=" + b + ",a=" + a);
54     }
55  
56 }

答案是不一定,测试结果如表1-1所示:

表1-1 测试结果

循环次数 串行执行耗时(单位ms 并发执行耗时 并发比串行快多少
1亿 130 77 约1倍
1千万 18 9 约1倍
1百万 5 5 差不多
10万 4 3
1万 0 1

从表1-1可以发现当并发执行累加操作不超过百万次时,速度会比串行执行累加操作要慢。那么为什么并发执行的速度还比串行慢呢?因为线程有创建和上下文切换的开销。

1.1.2    测试上下文切换次数和时长

下面我们来看看有什么工具可以度量上下文切换带来的消耗。

  • 使用Lmbench3[1]可以测量上下文切换的时长。
  • 使用vmstat可以测量上下文切换的次数。

下面是利用vmstat测量上下文切换次数的示例。

01 $ vmstat 1
02  
03 procs -----------memory---------- ---swap-- -----io---- --system-- -----cpu-----
04  
05 r b   swpd   free   buff cache   si   so   bi   bo   in   cs us sy id wa st
06  
07 0 0     0 127876 398928 2297092   0   0     0     4   2   2 0 0 99 0 0
08  
09 0 0     0 127868 398928 2297092   0   0     0     0 595 1171 0 1 99 0 0
10  
11 0 0     0 127868 398928 2297092   0   0     0     0 590 1180 1 0 100 0 0
12  
13 0 0     0 127868 398928 2297092   0   0     0     0 567 1135 0 1 99 0 0

CS(Content Switch)表示上下文切换的次数,从上面的测试结果中,我们可以看到其中上下文的每一秒钟切换1000多次。

1.1.3    如何减少上下文切换

减少上下文切换的方法有无锁并发编程、CAS算法、单线程编程和使用协程。

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据用ID进行Hash算法后分段,不同的线程处理不同段的数据。
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

 

1.1.4    减少上下文切换实战

本节描述通过减少线上大量WAITING的线程,来减少上下文切换次数。

第一步:用jstack命令 dump线程信息,看看pid是3117进程里的线程都在做什么。

1 sudo -u admin /opt/ifeve/java/bin/jstack 31177 &gt; /home/tengfei.fangtf/dump17

第二步:统计下所有线程分别处于什么状态,发现300多个线程处于WAITING(onobjectmonitor)状态。

1 [tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c
2 39 RUNNABLE
3 21 TIMED_WAITING(onobjectmonitor)
4 6 TIMED_WAITING(parking)
5 51 TIMED_WAITING(sleeping)
6 305 WAITING(onobjectmonitor)
7 3 WAITING(parking)

第三步:打开dump文件查看处于WAITING(onobjectmonitor)的线程在做什么。发现这些线程基本全是JBOSS的工作线程在await。说明JBOSS线程池里线程接收到的任务太少,大量线程都闲着。

1 "http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Object.wait() [0x0000000052423000]
2  java.lang.Thread.State: WAITING (on object monitor)
3  at java.lang.Object.wait(Native Method)
4  - waiting on <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
5  at java.lang.Object.wait(Object.java:485)
6  at org.apache.tomcat.util.net.AprEndpoint$Worker.await(AprEndpoint.java:1464)
7  - locked <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker)
8  at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpoint.java:1489)
9  at java.lang.Thread.run(Thread.java:662)

第四步:减少JBOSS的工作线程数,找到JBOSS的线程池配置信息,将maxThreads降低到100。

1 <maxThreads="250" maxHttpHeaderSize="8192"
2 emptySessionPath="false" minSpareThreads="40" maxSpareThreads="75" maxPostSize="512000" protocol="HTTP/1.1"
3 enableLookups="false" redirectPort="8443" acceptCount="200" bufferSize="16384"
4 connectionTimeout="15000" disableUploadTimeout="false" useBodyEncodingForURI="true">

第五步:重启JBOSS,再dump线程信息,然后再统计WAITING(onobjectmonitor)的线程,发现减少了175。WAITING的线程少了,系统上下文切换的次数就会少,因为从WAITTING到RUNNABLE会进行一次上下文的切换。读者也可以使用vmstat命令测试下。

1 [tengfei.fangtf@ifeve ~]$ grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c
2 44 RUNNABLE
3 22 TIMED_WAITING(onobjectmonitor)
4 9 TIMED_WAITING(parking)
5 36 TIMED_WAITING(sleeping)
6 130 WAITING(onobjectmonitor)
7 1 WAITING(parking)

1.2 死锁

锁是个非常有用的工具,运用场景非常多,因为其使用起来非常简单,而且易于理解。但同时它也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,会造成系统功能不可用。让我们先来看一段代码,这段代码会引起死锁,线程t1和t2互相等待对方释放锁。

01 package chapter01;
02  
03 /**
04  * 死锁例子
05  *
06  * @author tengfei.fangtf
07  * @version $Id: DeadLockDemo.java, v 0.1 2015-7-18 下午10:08:28 tengfei.fangtf Exp $
08  */
09 public class DeadLockDemo {
10  
11     /** A锁 */
12     private static String A = "A";
13     /** B锁 */
14     private static String B = "B";
15  
16     public static void main(String[] args) {
17         new DeadLockDemo().deadLock();
18     }
19  
20     private void deadLock() {
21         Thread t1 = new Thread(new Runnable() {
22             @Override
23             public void run() {
24                 synchronized (A) {
25                     try {
26                         Thread.sleep(2000);
27                     catch (InterruptedException e) {
28                         e.printStackTrace();
29                     }
30                     synchronized (B) {
31                         System.out.println("1");
32                     }
33                 }
34             }
35         });
36  
37         Thread t2 = new Thread(new Runnable() {
38             @Override
39             public void run() {
40                 synchronized (B) {
41                     synchronized (A) {
42                         System.out.println("2");
43                     }
44                 }
45             }
46         });
47         t1.start();
48         t2.start();
49     }
50  
51 }

这段代码只是演示死锁的场景,在现实中你可能很难会写出这样的代码。但是一些更为复杂的场景中你可能会遇到这样的问题,比如t1拿到锁之后,因为一些异常情况没有释放锁,比如死循环。又或者是t1拿到一个数据库锁,释放锁的时候抛了异常,没释放掉。

一旦出现死锁,业务是可感知的,因为不能继续提供服务了,那么只能通过dump线程看看到底是哪个线程出现了问题,以下线程信息告诉我们是DeadLockDemo类的42行和31号引起的死锁:

01 "Thread-2" prio=5 tid=7fc0458d1000 nid=0x116c1c000 waiting for monitor entry [116c1b000]
02    java.lang.Thread.State: BLOCKED (on object monitor)
03         at com.ifeve.book.forkjoin.DeadLockDemo$2.run(DeadLockDemo.java:42)
04         - waiting to lock <7fb2f3ec0> (a java.lang.String)
05         - locked <7fb2f3ef8> (a java.lang.String)
06         at java.lang.Thread.run(Thread.java:695)
07  
08 "Thread-1" prio=5 tid=7fc0430f6800 nid=0x116b19000 waiting for monitor entry [116b18000]
09    java.lang.Thread.State: BLOCKED (on object monitor)
10         at com.ifeve.book.forkjoin.DeadLockDemo$1.run(DeadLockDemo.java:31)
11         - waiting to lock <7fb2f3ef8> (a java.lang.String)
12         - locked <7fb2f3ec0> (a java.lang.String)
13         at java.lang.Thread.run(Thread.java:695)

现在我们介绍下如何避免死锁的几个常见方法。

  • 避免一个线程同时获取多个锁。
  • 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
  • 尝试使用定时锁,使用tryLock(timeout)来替代使用内部锁机制。
  • 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败。

1.3 资源限制的挑战

(1)什么是资源限制?

资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源的限制。比如服务器的带宽只有2M,某个资源的下载速度是1M每秒,系统启动十个线程下载资源,下载速度不会变成10M每秒,所以在进行并发编程时,要考虑到这些资源的限制。硬件资源限制有带宽的上传下载速度,硬盘读写速度和CPU的处理速度。软件资源限制有数据库的连接数和Sorket连接数等。

(2)资源限制引发的问题

并发编程将代码执行速度加速的原则是将代码中串行执行的部分变成并发执行,但是如果某段串行的代码并发执行,但是因为受限于资源的限制,仍然在串行执行,这时候程序不仅不会执行加快,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,之前看到一段程序使用多线程在办公网并发的下载和处理数据时,导致CPU利用率100%,任务几个小时都不能运行完成,后来修改成单线程,一个小时就执行完成了。

 

(3)如何解决资源限制的问题?

对于硬件资源限制,可以考虑使用集群并行执行程序,既然单机的资源有限制,那么就让程序在多机上运行,比如使用ODPS,hadoop或者自己搭建服务器集群,不同的机器处理不同的数据,比如将数据ID%机器数,得到一个机器编号,然后由对应编号的机器处理这笔数据。

对于软件资源限制,可以考虑使用资源池将资源复用,比如使用连接池将数据库和Sorket连接复用,或者调用对方webservice接口获取数据时,只建立一个连接。

 

(4)在资源限制情况下进行并发编程

那么如何在资源限制的情况下,让程序执行的更快呢?根据不同的资源限制调整程序的并发度,比如下载文件程序依赖于两个资源,带宽和硬盘读写速度。有数据库操作时,要数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接数大很多,则某些线程会被阻塞住,等待数据库连接。

1.4 本章小结

本章介绍了在进行并发编程的时候,大家可能会遇到的几个挑战,并给出了一些解决建议。有的并发程序写的不严谨,在并发下如果出现问题,定位起来会比较耗时和棘手。所以对于Java开发工程师,笔者强烈建议多使用JDK并发包提供的并发容器和工具类来帮你解决并发问题,因为这些类都已经通过了充分的测试和优化,解决了本章提到的几个挑战。

[1] Lmbench3是一个性能分析工具。

上一篇:我的博客即将入驻“云栖社区”,诚邀技术同仁一同入驻。


下一篇:支持ESMTP身份验证的邮件发送