首先什么是并发编程?
并发编程是指在相同时间间隔内,多个事件按照一定的顺序进行。比如说你在吃饭的整个过程中,吃了米饭、吃了蔬菜、吃了牛肉。吃米饭、吃蔬菜、吃牛肉这三件事其实就是并发执行的。
并发编程的目的是为了程序运行得更快,但是并不是启动更多的线程就能让程序最大限度地并发执行。在并发编程过程中,如果希望程序运行得更快,就会面临着非常多的挑战,比如上下文切换,死锁问题,以及受限于硬件和软件的资源问题,接下来对这几个问题进行详细的介绍。
上下文切换问题
在单核处理器中,CPU给每个线程分配CPU时间片来实现多线程并发,在切换到下个线程之前会保存当前线程的任务状态,下次再切换回这个任务时可以加载这个任务的状态,任务从保存到加载的过程就是一次上下文切换,在多线程编程中,由上下文切换造成的损耗是会影响线程的执行效率的。
上面我们讲到上下文切换存在效率的损耗,所以,大家能够接受多线程不一定会比单线程快。下面我们以一段简单的代码为测试例子,一万次,百万,千万,一亿,十亿次测试:
public class ConcurrencyTest {
private static final long count = 100000000l;
public static void concurrency() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(() -> {
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--;
}
thread.join();
long time = System.currentTimeMillis()-start;
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);
}
public static void main(String[] args) throws InterruptedException {
concurrency();
serial();
}
}
一万次:
concurrency:60ms,b=-10000
serial:0ms,b=-10000
百万次:
concurrency:83ms,b=-1000000
serial:5ms,b=-1000000
千万次:
concurrency:70ms,b=-10000000
serial:12ms,b=-10000000
一亿次:
concurrency:98ms,b=-100000000
serial:85ms,b=-100000000
十亿次:
concurrency:462ms,b=-1000000000
serial:734ms,b=-1000000000
上面测试结果显示,当一个简单的计算只要结果大于十亿次并发累加才会比串行快。
那么怎么减少上下文切换呢?
- 无锁编程。多线程竞争锁时会引起上下文切换,所以多线程处理数据时可以用一些办法来避免使用锁,如将数据的ID按照hash算法取模,不同的线程处理不同的数据。
- CAS算法。java的Atomic包使用的CAS算法来更新数据,而不需要加锁。
- 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态。
- 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
死锁问题
锁是非常有用的工具,运用场景非常多,因为它使用起来非常简单,而且易于理解。但是它同时也会带来一些困扰,那就是可能会引起死锁,一旦产生死锁,就会造成系统功能不可用。比如以下代码就是造成两个线程相互等待,程序无法结束。
public class DeadLock {
private static String A = "A";
private static String B = "B";
public static void main(String[] args) {
new DeadLock().deadLock();
}
private void deadLock(){
Thread t1 = new Thread(()->{
synchronized (A){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (B){
System.out.println("1");
}
}
});
Thread t2 = new Thread(()->{
synchronized (B){
try {
Thread.currentThread().sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (A){
System.out.println("2");
}
}
});
t1.start();
t2.start();
}
}
一旦出现问题,程序就会卡死在那里,只能通过dump线程查找原因。
那么怎么避免死锁呢,有以下几点方法:
- 避免一个线程同时获得多个锁。
- 避免一个线程在所内同时占用资源,保证每个锁只占用一个资源。
- 尝试使用定时锁,使用Lock.tryLock(timeout)来替换使用内部锁机制。
- 对于数据库,加锁和解锁必须在一个数据库连接里,否则就会出现解锁失败的情况。
资源限制的挑战
什么是资源限制?
资源限制是指在进行并发编程时,程序的执行速度受限于计算机硬件资源或者软件资源。例如,服务器的带宽只有2Mb/s,某个资源的下载速度是1Mb/s,系统启动10个线程下载资源,下载速度不会变成10Mb/s,所以在进行并发编程时,要考虑这些资源的限制。硬件资源有带宽的上传和下载速度,硬盘的读写速度和CPU的处理速度。软件资源限制有数据库连接数和socket连接数等。
资源限制引发的问题
比如一段代码,原本是串行执行的,如果将某段串行的代码并发执行,因为受限于资源,仍在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。例如,使用多线程下载资源,有时候不如单线程下载快。
如何解决资源限制的问题
对于硬件资源,可以考虑集群化部署,对于软件资源,可以考虑资源池将资源复用,比如使用连接处将数据库和Sockert连接复用,或者在调用对方webservice接口数据时,只建立一个连接。
在资源限制的情况下进行并发编程
根据不同的资源限制调整程序的并发度,比如在下载文件程序依赖两个资源:带宽和硬盘读写速度。有数据库操作,涉及数据库连接数,如果SQL语句执行非常快,而线程的数量比数据库连接大很多,则某些线程会被阻塞,等待数据库连接。
小结
在上面介绍了什么是并发编程,以及并发编程带来的挑战以及一些应对策略,在这里推荐大家多使用jdk的并发包提供的容器和工具类来解决并发问题,因为这些类都已经充分的测试好优化,可以避免很多问题。