一:并发编程的挑战
并发编程主要目的是为了在相同的时间内让程序运行得更快或者处理更多的任务。为了实现这一目的,并发编程会面临很多的挑战。并发编程必然会带来多线程之间的问题,比如上下文之间的切换,线程死锁,以及硬件资源的限制。
(一):上下文的切换
无论是单核还是多核处理器都支持多线程。在单核处理器中,CPU会给每个线程分配时间片来实现多线程,其实这是一种错觉,单核处理器在不停的进行线程之间的切换,时间片非常短,以至于我们无法通过肉眼感知。
CPU通过内核调度来分配时间片。当前线程时间片执行完成后会切换到另一个线程。在进行线程切换的时候会保存上一个时间片任务的状态,以便下次切换回这个任务时,可以再加载这个任务的状态。所以任务从保存到再加载的过程就是一次上下文切换。
1.1:多线程的使用
使用的线程越多,上下文的切换越频繁。比如内核分配的时间片是20ms,做进程切换的时间是1ms,那么如果需要处理的任务在20ms中无法完成,就需要额外增加1ms的上下文切换消耗。
示例代码:(该代码来源于《并发编程的艺术》)
private static final long count = 100000000;
public static void main(String[] args) throws Exception {
concurrencyMethod1();
serialMethod2();
}
private static void concurrencyMethod1() throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
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 serialMethod2() {
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);
}
结果:上述代码中两个方法执行的时间无法确定,数量越大,多线程的速度越快,这个也取决于CPU的性能。在数量很小的时候,串行的方法速度很快。原因有两点:(1)创建线程对于系统的开销比较大;(2)串行没有上下文的切换。
1.2:减少上下文的切换
既然知道上下文切换会带来一些性能上的问题,那么我们应该如何减少上下文的切换呢?有以下几种方式:
-
无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁。将数据进行分组分段,不同的线程处理不同段的数据。例子:班级收作业,4个组长相当于4个线程,将学生进行分组,每个学生只属于一个组,每个组长只负责自己组的同学。而不是4个组长一起负责全班同学收作业。
-
CAS算法。比较并交换。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
-
合理的使用线程。尽量避免创建不需要的线程,创建线程的开销并来就很大,过多的线程会一直等待。
-
协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。
减少上下文的切换主要就是通过减少不必要的线程数。
(二):线程死锁
在并发编程中,为了保证操作的原子性,我们会经常使用锁。在开发中,锁是个非常有用的工具,使用的场景非常多,使用起来比较简单。但同时它也会带来一些问题,可能会引起死锁,一旦产生死锁,会导致系统拥堵,严重情况下系统直接不可使用。下面这段代码演示了死锁的一种情况
public static void main(String[] args) {
Thread t2 = new Thread(new Runnable() {
public void run() {
deadLock("A0001", "线程2");
}
});
Thread t1 = new Thread(new Runnable() {
public void run() {
deadLock("A0001", "线程1");
}
});
t2.start();
t1.start();
}
private static void deadLock(String code, String threadName) {
synchronized (code.intern()) {
while (true) { // 假如while中的条件为true
System.out.println(threadName + "使用中。。。。。。。。。。。。。。。");
}
}
}
这里的代码只是演示效果,一般情况下不会出现这样的问题。但如果不能保证拿到锁的对象及时释放锁,这会产生很严重的问题。撑爆硬件资源,直接导致系统不可用。以下是几种避免死锁的方式:
- 可以使用定时锁,使用boolean tryLock(long time, TimeUnit unit) throws InterruptedException来获取使用锁。
- 对于数据库锁,加锁和解锁必须在一个数据库连接里,否则会出现解锁失败的情况。
- 避免一个线程同时获取多个锁。
- 避免一个线程在锁内同时占用多个资源,尽量保证每个锁只占用一个资源。
(三):硬件资源限制
(1)什么是资源限制
程序在运行时依赖于计算机的硬件和软件。比如说程序上传一个10GB的大资源,这时计算机的带宽,硬盘的读写速度等会带来一些影响。上传资源的速度限制为20M/S,那么不考虑其他因素,理想情况下至少需要10 * 1024 /20的时间。就算将10GB的资源切割成10个1GB的资源一起上传,但最终上传的限制依旧是20M/S,对于上传的速度不会带来提升。
软件资源限制有数据库的连接数和socket连接数等。
(2)资源限制引发的问题
在并发编程中,将代码执行速度加快的原则是将代码中串行执行的部分变成并发执行。但是如果将某段串行的代码并发执行,因为受限于资源,仍然在串行执行,这时候程序不仅不会加快执行,反而会更慢,因为增加了上下文切换和资源调度的时间。
(3)如何解决资源限制的问题
对于硬件资源限制,可以垂直提升硬件,比如说提升服务器的配置,增强处理器,放大带宽等。对于软件资源限制,线程池的复用,连接池的复用。
(4)在资源限制情况下进行并发编程
程序的运行必然会受到硬件和软件资源的限制。如何在资源限制的情况下,让程序执行得更快呢?最重要的方法就是,根据不同的资源限制调整程序的并发度。