《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是一个性能分析工具。

相关文章
|
1天前
|
Java API 调度
[AIGC] 深入理解Java并发编程:从入门到进阶
[AIGC] 深入理解Java并发编程:从入门到进阶
|
1天前
|
Dubbo Java 应用服务中间件
Java从入门到精通:3.2.2分布式与并发编程——了解分布式系统的基本概念,学习使用Dubbo、Spring Cloud等分布式框架
Java从入门到精通:3.2.2分布式与并发编程——了解分布式系统的基本概念,学习使用Dubbo、Spring Cloud等分布式框架
|
2天前
|
Java
Java中的并发编程:理解和应用线程池
【4月更文挑战第23天】在现代的Java应用程序中,性能和资源的有效利用已经成为了一个重要的考量因素。并发编程是提高应用程序性能的关键手段之一,而线程池则是实现高效并发的重要工具。本文将深入探讨Java中的线程池,包括其基本原理、优势、以及如何在实际开发中有效地使用线程池。我们将通过实例和代码片段,帮助读者理解线程池的概念,并学习如何在Java应用中合理地使用线程池。
|
6天前
|
安全 Java 开发者
Java并发编程:深入理解Synchronized关键字
【4月更文挑战第19天】 在Java多线程编程中,为了确保数据的一致性和线程安全,我们经常需要使用到同步机制。其中,`synchronized`关键字是最为常见的一种方式,它能够保证在同一时刻只有一个线程可以访问某个对象的特定代码段。本文将深入探讨`synchronized`关键字的原理、用法以及性能影响,并通过具体示例来展示如何在Java程序中有效地应用这一技术。
|
7天前
|
安全 Java 调度
Java并发编程:深入理解线程与锁
【4月更文挑战第18天】本文探讨了Java中的线程和锁机制,包括线程的创建(通过Thread类、Runnable接口或Callable/Future)及其生命周期。Java提供多种锁机制,如`synchronized`关键字、ReentrantLock和ReadWriteLock,以确保并发访问共享资源的安全。此外,文章还介绍了高级并发工具,如Semaphore(控制并发线程数)、CountDownLatch(线程间等待)和CyclicBarrier(同步多个线程)。掌握这些知识对于编写高效、正确的并发程序至关重要。
|
7天前
|
安全 Java 程序员
Java中的多线程并发编程实践
【4月更文挑战第18天】在现代软件开发中,为了提高程序性能和响应速度,经常需要利用多线程技术来实现并发执行。本文将深入探讨Java语言中的多线程机制,包括线程的创建、启动、同步以及线程池的使用等关键技术点。我们将通过具体代码实例,分析多线程编程的优势与挑战,并提出一系列优化策略来确保多线程环境下的程序稳定性和性能。
|
8天前
|
缓存 分布式计算 监控
Java并发编程:深入理解线程池
【4月更文挑战第17天】在Java并发编程中,线程池是一种非常重要的技术,它可以有效地管理和控制线程的执行,提高系统的性能和稳定性。本文将深入探讨Java线程池的工作原理,使用方法以及在实际开发中的应用场景,帮助读者更好地理解和使用Java线程池。
|
9天前
|
缓存 监控 Java
Java并发编程:线程池与任务调度
【4月更文挑战第16天】Java并发编程中,线程池和任务调度是核心概念,能提升系统性能和响应速度。线程池通过重用线程减少创建销毁开销,如`ThreadPoolExecutor`和`ScheduledThreadPoolExecutor`。任务调度允许立即或延迟执行任务,具有灵活性。最佳实践包括合理配置线程池大小、避免过度使用线程、及时关闭线程池和处理异常。掌握这些能有效管理并发任务,避免性能瓶颈。
|
9天前
|
设计模式 运维 安全
深入理解Java并发编程:线程安全与性能优化
【4月更文挑战第15天】在Java开发中,多线程编程是提升应用程序性能和响应能力的关键手段。然而,它伴随着诸多挑战,尤其是在保证线程安全的同时如何避免性能瓶颈。本文将探讨Java并发编程的核心概念,包括同步机制、锁优化、线程池使用以及并发集合等,旨在为开发者提供实用的线程安全策略和性能优化技巧。通过实例分析和最佳实践的分享,我们的目标是帮助读者构建既高效又可靠的多线程应用。
|
11天前
|
Java 编译器
Java并发编程中的锁优化策略
【4月更文挑战第13天】 在Java并发编程中,锁是一种常见的同步机制,用于保证多个线程之间的数据一致性。然而,不当的锁使用可能导致性能下降,甚至死锁。本文将探讨Java并发编程中的锁优化策略,包括锁粗化、锁消除、锁降级等方法,以提高程序的执行效率。
13 4