并发编程的目的是为了程序运行得更快,但并不是启动更多线程就能让程序最大程度地并发执行
在通过多线程执行任务让程序运行得更快将面临很多挑战
一、上下文切换
1.1 什么是上下文切换
即使是单核CPU也支持多线程代码执行,CPU通过给每个线程分配CPU时间片来实现这个机制
时间片
CPU分配给各个线程的时间
因为时间片非常短,所以CPU通过不断切换线程执行,我们感觉多个线程是同时执行的
时间片一般是几十毫秒(ms)
目的
计算机通过计算时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态
所以任务从保存到再加载的过程就是一次上下文切换
1.2 多线程一定快么
代码串行执行和并发执行累计的时间,串行一定比并发执行慢么?
首先看一段代码
public class ConcurrencyTest {
private static final long count = 1000000l;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
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--;
}
long time = System.currentTimeMillis() - start;
thread.join();
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+",a="+a);
}
}
不同量级的循环执行的测试结果
循环次数 | 串行执行耗时/ms | 并发执行耗时/ms |
---|---|---|
10亿 | 1255 | 641 |
1亿 | 91 | 68 |
1000万 | 16 | 12 |
100万 | 7 | 7 |
10万 | 3 | 5 |
1万 | 0 | 1 |
可以看出,当并发执行累加操作不超过100w次时,速度会比串行累加操作要慢
为什么会出现这样的情况呢?这是因为线程有创建和上下文切换的开销
1.3 上下文切换的次数和时长
有什么工具可以度量上下文切换带来的消耗
Lmbench3
一个性能分析工具,测量上下文切换的时长
vmstat
测量上下文切换的次数
CS(Content Switch)表示上下文切换的次数,从上面的测试结果可以看出,上下文切换的次数为每秒2000次左右
1.4 如何减少上下文切换
减少上下文切换的方法
-
无锁并发编程
多线程竞争锁时,会引起上下文切换
多线程处理数据时,可以使用一些方式避免使用锁,比如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据 -
CAS算法
Java的Atomic包使用CAS算法来更新数据,而不需要加锁 -
使用最少线程
避免创建不需要的线程
比如任务很少,但是创建了很多线程来处理,这样会造成大量的线程都处于等待状态 -
协程
在单线程中实现多任务的调度,并在单线程中维持多个任务间的切换
1.5 减少上下文切换实战
如何定位分析并减少大量WAITING的线程,来减少上下文切换的次数
1. jstack命令dump线程信息
看看pid为1的进程里的线程都在做什么
jstack 1 > /app/dump17
2. 统计线程都处于什么状态
grep java.lang.Thread.State dump17 | awk '{print $2$3$4$5}' | sort | uniq -c
22 RUNNABLE
5 TIMED_WAITING(onobjectmonitor)
42 TIMED_WAITING(parking)
8 TIMED_WAITING(sleeping)
305 WAITING(onobjectmonitor)
39 WAITING(parking)
3. 查看 WAITING线程
打开dump文件查看处于WAITING(onobjectmonitor)的线程在做什么
"http-0.0.0.0-7001-97" daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Object.wait() [0x0000000052423000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) - waiting on <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker) at java.lang.Object.wait(Object.java:485) at org.apache.tomcat.util.net.AprEndpoint$Worker.await(AprEndpoint.java:1464) - locked <0x00000007969b2280> (a org.apache.tomcat.util.net.AprEndpoint$Worker) at org.apache.tomcat.util.net.AprEndpoint$Worker.run(AprEndpoint.java:1489) at java.lang.Thread.run(Thread.java:662)
发现这些线程基本上都是JBOSS的工作线程,处于await
说明JBOSS线程池中的线程接收到的任务太少,大量的线程都闲着
4. 减少JBOSS的工作线程数
找到JBOSS的线程池配置信息,将maxThreads降低到100
<maxThreads="250" maxHttpHeaderSize="8192" emptySessionPath="false" minSpareThreads="40" maxSpareThreads="75" maxPostSize="512000" protocol="HTTP/1.1" enableLookups="false" redirectPort="8443" acceptCount="200" bufferSize="16384" connectionTimeout="15000" disableUploadTimeout="false" useBodyEncodingForURI= "true">
5. 重启JBOSS
再dump线程信息,然后统计WAITING(onobjectmonitor)的线程数量,发现处于该状态的线程大量减少
WAITING状态的线程少了,系统上下文切换的次数就会少,因为每一次从WAITING到RUNNABLE都会进行一次上下文切换
二、死锁
锁在并发编程中非常常见和实用,但也会带来一些困扰,那就是可能引起死锁,一旦尝试死锁,就会造成系统功能不可用
2.1 死锁实例
以下的一段代码,线程t1和线程t2相互等待对方释放锁,这段代码会引起死锁
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(new Runnable() {
@Override
public void run() {
synchronized (A) {
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B) {
System.out.println("1");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
}
});
t1.start();
t2.start();
}
}
现实中可能并不是直接写这样的代码,但是一些更为复杂的场景中,可以会遇到这样的问题:
- t1拿到锁后,因为一些异常情况没有释放锁(死循环)
- t1拿到一个数据库锁,释放锁的时候抛出了异常,没释放掉
2.2 死锁问题确定
一旦出现死锁的情况,业务是有感知的,因为不能继续提供服务了,可以通过dump线程查看到底是哪个线程出现了问题
1. 查看服务的进程id
ps -ef | grep DeadLockDemo
2. dump进程信息
jstack 58791 > /Users/lluozh/docs/dump17
3. 搜索dump文件中BLOCKED进程
"Thread-1" #11 prio=5 os_prio=31 tid=0x00007fa432b74000 nid=0x4003 waiting for monitor entry [0x0000700007dab000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.cvte.fagent.lluozh.DeadLockDemo$2.run(DeadLockDemo.java:37)
- waiting to lock <0x0000000795f70e98> (a java.lang.String)
- locked <0x0000000795f70ec8> (a java.lang.String)
at java.lang.Thread.run(Thread.java:748)
"Thread-0" #10 prio=5 os_prio=31 tid=0x00007fa4343c9000 nid=0x4203 waiting for monitor entry [0x0000700007ca8000]
java.lang.Thread.State: BLOCKED (on object monitor)
at com.cvte.fagent.lluozh.DeadLockDemo$1.run(DeadLockDemo.java:27)
- waiting to lock <0x0000000795f70ec8> (a java.lang.String)
- locked <0x0000000795f70e98> (a java.lang.String)
at java.lang.Thread.run(Thread.java:748)
2.3 避免死锁常见方法
1. 避免一个线程同时获取多个锁
2. 避免一个线程在锁内同时占用多个资源
尽量保证每个锁只占用一个资源
3. 尝试使用定时锁
使用lock.tryLock(timeout)
来代替使用内部锁机制
4. 数据库锁
加锁和解锁必须在一个数据库连接中,否则会出现数据库解锁失败的情况
三、资源限制的挑战
3.1 什么是资源限制
资源限制指的是在并发编程时,程序的执行速度受限于计算机硬件资源或软件资源
例如:
服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s,系统启动10个线程下载资源,下载速度不会变成10Mb/s
在进行并发编程时,要考虑资源的限制
硬件资源限制
- 带宽的上传/下载速度
- 硬盘读写速度
- CPU的处理速度
软件资源限制
- 数据库的连接数
- socket连接数
3.2 资源限制引发的问题
代码执行速度加快的原则:
将代码中串行执行的部分变成并发执行
资源受限引发的问题
如果将某段串行执行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会变慢,因为增加上下文切换和资源调度的时间
3.3 如何解决资源限制的问题
硬件资源
集群并发
对于硬件资源的限制,可以考虑使用集群并发执行程序,既然单机的资源有限制,那么就让程序在多机上运行
分布式处理
不同的机器处理不同的数据,可以通过"数据ID%机器数"
,计算得到一个机器编号,然后由对应编号的机器处理对应的数据
软件资源
资源池复用
对于软件资源的限制,可以考虑使用资源池将资源复用,比如
- 使用连接池将数据库和
socket
连接复用 - 在调用对方
webservice
接口获取数据时只建立一个连接
3.4 资源限制情况下并发编程
根据不同的资源限制调整程序的并发度
下载文件
涉及:
- 带宽
- 硬盘的读写速度
根据带宽和硬盘的读写速度调整并发执行线程数
数据库操作
涉及:
- 数据库连接
如果SQL语句执行非常快,而线程的数量比数据库的连接数大很多,则某些线程会被阻塞,等待数据库连接