众所周知,并发编程的目的是为了提高程序的效率。但是也不是说只要启动更多的线程就能让程序最大限度的并发执行来提高效率。在并发编程的道路上会有很多挑战,比如上下文切换,死锁,以及硬件和软件的资源限制等。下面我们就来了解和解决(避免)此类问题的发生。
上下文切换
在最早的单核处理器的时代,也支持多线程执行代码。CPU通过给每个线程分配CPU时间片来实现这个机制。假如现在有两个线程t1和t2来并发执行任务。当线程t1任务执行还未执行完,此时时间片用完了。CPU会把时间分配给t2来执行。也就是这种切换的过程称为上下文切换。因为每次分配的时间片的时间很短,所以需要不停的进行上下文切换来完成任务。但是这样频繁的上下文切换也是会消耗性能的,从而影响多线程的执行速度。
多线程一定快吗?不一定的。为什么说不一定,如下面的代码:
package com.swh;
public class Test {
public static int count = 10000;
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
private static void serial() {
long start = System.currentTimeMillis();
int a = 0,b = 0;
for (int i = 0; i < count; i++) {
a++;
}
for (int i = 0; i <count ; i++) {
b--;
}
long end = System.currentTimeMillis();
System.out.println(end-start);
}
private static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(() -> {
int a = 0;
for (int i = 0; i < count; i++) {
a++;
}
});
thread.start();
int b = 0;
for (int i = 0; i <count; i++) {
b--;
}
thread.join();
long end = System.currentTimeMillis();
System.out.println(end-start);
}
}
经过测试:
循环次数 串行执行耗/ms 并行执行耗时 并发比串行快多少
count=1亿 130 77 约1倍
count=1千万 18 9 约1倍
count=1百万 5 5 差不多
count=10万 4 3 差不多
count=1万 0 1 慢
由上面的代码测试可知(由于每个机器的硬件资源及环境不一样所以我的测试数据不一定和读者的测试数据一致),当并发执行累加操作不超过百万次时,速度会比串行慢或差不多。为什么并发会比串行慢呢?这是因为线程有创建和上下文切换的开销。我们可以通过使用Lmbench3来测量上下文切换的时长,可以通过使用java自带的vmstat测量上下文切换的次数。(有兴趣的读者可以去研究一下)。
如何减少上下文的切换
减少上下文切换的方法有无锁并发编程,CAS算法,使用最少线程和使用协程。
- 无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些方法来避免使用锁,如将数据ID按照hash算法取模分段,不同的线程处理不同段的数据
- CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
- 使用最小线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
减少上下文实战
减少上下文切换的思路:通过减少线上大量的WAITINA状态的线程来减少上下文的切换。
第一步: 使用jstack命令dump线程信息,看看java进程中的线程都是什么状态
Sudo -u admin /opt/java/bin/jstack pid > /opt/dump
/opt/java/bin/ 是java jd安装的路径
pid 是java进程id
/opt/dump 存放栈信息的目录
第二步:统计所有线程分别处于什么状态
grep java.lang.Thread.State dump | awk ‘{print $2$3$4$5}’ | sort | uniq -c
39 RUNNABLE
21 TIMED_WAITING(onobjectmonitor)
6 TIMED_WAITING(parking)
28 TIMED_WAITING(sleeping)
305 WAITING (onobjectmonitor)
3 WAITING (parking)
第三步: 打开dump文件查看处于WAITING(onobjectmonitor)的线程都在干什么,是否可以停掉或者优化。
死锁
锁是一个非常有用的工具,但是他同时也会带来一些问题,那就是他有可能会会引起死锁。一旦产生死锁,就会造成系统功能不可用,是非常严重的。下面的代码会产生死锁。
package com.swh;
public class DeadLock {
private static final String A = "A";
private static final String B = "B";
public static void main(String[] args) {
new DeadLock().deadLock();
}
public void deadLock(){
Thread thread = new Thread(() -> {
synchronized (A){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B){
System.out.println("1");
}
};
});
Thread thread1 = new Thread(() -> {
synchronized (B) {
synchronized (A) {
System.out.println("2");
}
}
});
thread.start();
thread1.start();
}
}
上面的代码产生死锁的原因是 thread 拿到了临界资源A(锁A) 等待获取临界资源B (锁B)。thread1拿到了临界资源B(锁B) 等待获取临界资源A (锁A)。然后thread和thread1互相等待拿到对方的锁从而造成死锁。一旦产生死锁,业务是可感知的,因为不能继续提供服务了。可以通过dump线程查看出是哪个线程出了问题,然后解决问题。
我们说几个避免死锁的方法:
- 避免一个线程中同时获取多个锁
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源
- 尝试使用定时锁,使用lock.tryLock(timeout)来代替使用内部锁的机制
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况
资源限制
什么是资源限制?
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。例如:服务器的带宽只有2Mb/s,某个资源的下载速度时1Mb/s每秒,系统启动10个线程下载资源,下载速度也不会变成10Mb/s。所以在进行并发编程的时候需要考虑这些资源的限制。硬件资源限制有宽带的上传/下载速度、硬盘读写速度和CPU的处理速度。软件资源限制有数据库连接数和socket连接数等。
资源限制引发的问题。
在并发编程中,如果将某段串行的代码为了提高并发效率给成了并发编程,有可能因为资源的受限,导致多线程仍处于串行执行。这样的话不仅不会提高运行效率反而会减低,因为增加了上下文切换和资源调度的时间。
如何解决资源限制
对于硬件的资源限制,很简单加硬件资源。一种是在单机上加硬件资源。一种是可以使用集群并行执行程序。既然单机上资源有限制那么就让程序在多多机上运行,当然集群也会带来问题这个我们以后来讨论。对于软件资源限制,可以考虑使用资源池将资源服用。比如使用数据库资源连接池等。
在资源限制情况下进行并发编程
如何在资源限制的情况下,让程序执行的更快呢?方法是,根绝不同的资源限制调整程序的并发度。