《java并发编程的艺术》读书笔记一

并发编程的挑战

并发编程的目的是为了让程序运行得更快,但是,并不是启动更多的线程就一定能让程序运行得更快,在进行并发编程时,会面临非常多的挑战,常见的问题如下:

  • 上下文切换,影响执行效率
  • 死锁,导致业务无法正常进行
  • 硬件软件资源的限制

上下文切换

单核处理器也支持多线程执行代码,CPU是通过给每个线程分配CPU时间片来实现这个机制。时间片一般是几十毫秒(ms),非常短暂,所以我们无法感知,感觉多个线程就是同时执行的。CPU通过时间片分配算法来循环执行任务,某个任务执行一个时间片后会切换到下一个任务,在切换前会保存上一个任务的执行状态,在下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。

多线程一定快吗

先看下面一段测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class ConcurrencyTest {

private static final long count = 100001;

public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}

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 + ",a=" + a);
}

private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
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--;
}
//join之后thread执行完成之后主线程才会继续执行,在此之前thread和main线程并发执行
thread.join();
long time = System.currentTimeMillis() - start;
System.out.println("concurrency :" + time + " ms, b=" + b);
}
}

建议读者朋友们可以先用上面代码在本机上实验一下,我的电脑测试结果如下(不同机器执行结果会有差异)

循环次数 串行执行耗时/ms 并发执行耗时 并发比串行快多少
1亿 80 42 快1倍
1千万 12 14
1百万 5 9
10万 3 5
1万 1 2

从表格中可以发现,当并发执行累加操作次数在1千万左右之前,并发执行比串行执行还要慢,为什么呢?这是因为线程有创建和上下文切换的开销。

如何减少上下文切换

  • 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以使用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。ConcurrentHashMap?
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,所以不需要加锁。乐观锁?
  • 使用最少线程,即尽量用最少的线程处理数据,减少了创建线程的开销,而且会出现大量线程处于等待状态的情况
  • 协程,在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换

减少上下文切换实战

书中使用JBOSS服务器做实验,作者使用springboot内置tomcat测试(此处为方便测试,tomcat最小空闲线程数有改动如下)

1
server.tomcat.minSpareThreads=50

第一步 启动springboot应用程序,使用jps获取pid,可以看到pid为70410

1
2
3
4
5
6
7
jungle@AppledeMacBook-Pro ~$ jps -l
70401 org.jetbrains.idea.maven.server.RemoteMavenServer
70441 sun.tools.jps.Jps
70411 org.jetbrains.jps.cmdline.Launcher
70410 com.jungle.Application
69999
jungle@AppledeMacBook-Pro ~$

第二步 用jstack命令dump线程信息,查看pid为70410的进程里的线程的工作状况

1
jungle@AppledeMacBook-Pro ~$ jstack 70410 > dumpFile

第三步 用WAITING搜索dump文件,发现有56个匹配

avatar

第四步 将最小空闲线程数配置为10,减少线程切换

1
server.tomcat.minSpareThreads=10

第五步 重启服务,重新dump文件并搜索WAITING

avatar

可以看到,处于等待状态的线程数减少了40个,WAITING的线程减少了,系统上下文切换的次数也会减少,因为每一次从WAITING到RUNNABLE都会进行一次上下文的切换

死锁

先看demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class DeadLockDemo {

private static String A = "A";
private static String B = "B";

public static void main(String[] args) {
new DeadLockDemo().deadLock();
}

private void deadLock() {
Thread t1 = new Thread(() -> {
synchronized (A) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
});

Thread t2 = new Thread(() -> {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
});
t1.start();
t2.start();
}
}

代码解释:以上代码运行时可能会出现这样一种情况,当t1线程获取到A锁后,在获取到B锁前,cpu执行到t2线程,t2线程获取到B锁,却拿不到A锁,此时A锁被t1线程持有,而t1线程又无法获取到B锁,两个线程互相等待对方释放锁,进入阻塞状态,程序无法正常执行.线程状态dump文件如图所示:

avatar

资源限制的挑战

  • 什么是资源限制

指在进行并发编程时,程序的执行速度受限于计算机硬件资源或软件资源,例如服务器的带宽限制或硬盘读写速度

  • 资源限制引发的问题

在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分编程并发执行,但是如果将某段串行的代码并发执行,由于资源限制导致仍然在串行执行,这时候由于上下文切换和资源调度,反而会影响执行效率

  • 如何解决资源限制的问题

    • 硬件:集群并行执行
    • 软件:使用资源池将资源复用,例如用连接池将数据库和Socket连接复用
  • 在资源限制情况下进行并发编程

如何在资源限制的情况下,让程序执行更快呢,方法是,根据不同的资源限制调整程序的并发度。比如,数据库操作时,如果sql语句执行非常快,线程的数量比数据库连接数大很多,则多出来的线程会被阻塞,等待数据库连接,此时可以选择增加数据库连接池的最大连接数,减少阻塞的线程,或者减少执行线程的数量,减少上下文切换导致的效率影响

小结

多使用JDK并发包提供的并发容器和工具类来解决并发问题,因为这些类都已经通过了充分的测试和优化,均可解决本章提到的几个挑战