前言
编写正确的程序很难,而编写正确的并发程序则难上加难。与串行程序相比,在并发程序中存在更多容易出错的地方。那么,为何我们还要使用并发程序?线程是Java语言中不可或缺的重要功能,它们能使复杂的异步代码变得简单,从而极大地简化了复杂系统的开发。此外,想要充分发挥多处理器系统的强大计算能力,最简单的方式就是使用线程。随着处理器数量的持续增长,如何高效地使用蝙蝠正变得越来越重要。同时在当今互联网的时代,大量的互联网应用都面对着海量的访问请求,因此,并发编程在我们的应用中成为越来越不可或缺的一部分。
并发简史
在早期的计算机中不包含操作系统,它们从头到尾只可以运行一个程序,操作系统的出现使得计算机每次能运行多个程序,并且不同的程序都在单独的进程中运行,之所以在计算机中加入操作系统来实现多个程序的同时执行,主要是基于下面几个原因:
- 资源利用率
在某些情况下,程序必须等待某个外部操作执行完成,而等待时程序无法执行其他任何工作。因此,如果在等待同时可以运行另一个程序,那么无疑将提高资源的利用率。 - 公平性
不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过时间分片使用户和程序可以共享计算机资源,而不是一个程序从头运行到底,再启动下一个程序。 - 便利性
通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更容易实现。
线程优势
如果使用得当,线程可以有效降低程序的开发和维护成本,同时提升复杂应用程序的性能。在服务器应用程序中,可以提升资源利用率以及系统吞吐率,线程还可以简化JVM的实现,垃圾收集器通常在一个或多个专门的线程中运行,在许多重要的Java应用中,都在一定程度生用到了线程。
发挥多处理器的强大能力
随着现在多处理器的普及,我们的服务器目前多数都是多个核心的,由于CPU的基本调度单位是线程,因此如果在程序中只有一个线程,那么最多同时只能在一个处理器上运行。在双处理器系统上,单线程的程序只能使用一半的CPU资源,而在拥有100个处理器的系统上,将有99%的资源无法使用。另一方面,多线程程序可同时在多个处理器上执行。如果设计正确,多线程程序可以通过提高处理器资源的利用率来提升系统吞吐率。
异步事件的简化处理
服务器应用程序在接受来自多个远程客户端的连接请求时,如果为每个连接都分配各自的线程并且使用同步I/O,那么就会降低这类程序的开发难度。
在单线程应用中,如果在处理某一请求过程中出现阻塞,意味着在这个线程被阻塞的期间,对所有请求的处理都将停顿。为了避免这个问题,单线程服务器应用程序必须使用非阻塞I/O,这种I/O的复杂性要远远高于同步I/O,并且很容易出错,如果每个请求都拥有自己的处理线程,那么在处理某个请求时发生的阻塞将不会影响其他请求的处理。
目前主流的Web容器,例如Tomcat,是支持多线程异步非阻塞模型来响应请求的,这样可以获得更大的请求吞吐量。
线程带来的风险
Java对线程的支持其实是一把双刃剑。虽然Java提供了相应的语言和库,以及一种明确的跨平台内存模型,这些工具简化的了并发应用程序的开发,但同时也挺高了对开发人员的技术要求,因为在更多的程序中会使用线程。
线程安全问题
线程安全性可能是非常复杂的,在没有充分同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至可能会出现奇怪的结果。下面的例子中,UnsafeSequence类中将产生一个整数值序列,该序列中的每个值都是唯一的。在这个类中简要的说明了多个线程之间的交替操作将如何导致不可预料的结果。在单线程环境中,这个类能正确地工作,但在多线程环境中则不可以。
public class UnsafeSequence {
private int value;
public int getNext() {
return value++;
}
public static void main(String[] args) {
UnsafeSequence unsafeSequence = new UnsafeSequence();
Executor executors = Executors.newFixedThreadPool(8);
for (int i = 0; i < 200; i++) {
executors.execute(()-> System.out.println(unsafeSequence.getNext()));
}
}
}
输出结果:
3 0 4 1 5 0 9 10 11 2 15 13 12 14 8 21 6 7 24 23 22 20 19 29 30 18 17 16 33 32 31 28 27 26 25 41 42 43 44 45 46 47 40 39 38 37 52 36 35 55 34 57 58 56 60 54 53 62 64 65 51 67 50 69 49 71 72 73 48 75 76 74 70 79 68 81 66 83 63 85 86 61 88 89 59 91 92 90 94 87 96 97 84 99 100 82 102 80 78 77 105 104 103 101 98 95 93 112 111 115 110 109 118 108 107 121 106 122 120 119 117 116 114 128 113 129 130 127 126 134 125 124 123 139 138 137 136 135 144 133 132 146 131 149 148 147 145 153 143 142 141 140 158 157 156 155 154 163 152 151 166 167 150 168 170 171 165 164 174 175 176 162 161 179 180 181 182 160 184 185 159 187 186 189 190 183 192 193 194 178 196 197 198 177 173 172 169 195 191 188
上面结果中没有出现199
UnsafeSequence的问题在于,如果执行时机不对,那么两个线程在调用getNext时会得到相同的值,虽然递增运算value++看上去是单个操作,但事实上它包含三个独立的操作:读取value,将value加1,并将计算结果写入value。由于运行时可能将多个线程之间的操作交替执行,因此这两个线程可能同时执行读操作,从而它们得到相同的值,并都将这个值加1,结果就是,在不同线程的调用中返回了相同的数值。
在UnsafeSequence类中说明的是一种常见的并发安全问题,称为竞态条件。在多线程环境下,getValue是否会返回唯一的值,要取决于运行时对线程中操作的交替执行方式,这并不是我们希望看到的情况。
由于多个线程要共享相同的内存地址空间,而且是并发运行,因此它们可能会访问或修改其他线程正在使用的变量。当然,这是一种极大的便利,因为这种方式比其他线程间通信机制更容易实现数据共享。但是它也带来了巨大的风险:线程会由于无法预料的数据变化而发生错误。当多个线程同时访问和修改相同的变量时,将会在串行编程模型中引入非串行因素,而这种非串行性是很难进行分析的。要使多线程程序的行为可以预测,必须对共享变量的访问操作进行协同,这样才不会在线程之间发生彼此干扰。幸运的是,Java提供了各种同步机制来协同这种访问。看下面的例子:
public class SafeSequence {
private int value;
public synchronized int getNext() {
return value++;
}
public static void main(String[] args) {
SafeSequence safeSequence = new SafeSequence();
Executor executors = Executors.newFixedThreadPool(8);
for (int i = 0; i < 200; i++) {
executors.execute(()-> System.out.println(safeSequence.getNext()));
}
}
}
活跃性问题
多线程会导致一些在单线程程序中不会出现的问题,例如活跃性问题。当某个操作无法继续执行下去时,就会发生活跃性问题。在串行程序中,活跃性问题的形式之一就是无意中造成的无限循环,从而使循环之后的代码无法得到执行。还有一些其他类型的问题,例如:如果线程A在等待线程B释放其持有的资源,而线程B永远都不释放该资源,那么A就会永远的等待下去。这就是通常所说的“死锁”。
性能问题
在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总会带来某种程度的运行时开销。再多线程程序中,当线程调度器临时挂起活跃线程并转而运行另一个线程时,就会频繁地出现上下文切换操作,这种操作将带来极大的开销:保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不是线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加共享内存总线的同步流量。所有这些因素都将带来额外的性能开销。
线程无处不在
即使在程序中没有显示地创建线程,但在框架中仍可能会创建线程,因此在这些线程中调用的代码同样必须是线程安全的。
每个Java应用程序都会使用线程。当JVM启动时,它将为JVM的内部任务,例如垃圾收集,终结操作等创建后台线程,并创建一个主线程来运行main方法。
并发编程中的重要概念
同步VS异步
同步和异步通常用来形容一次方法调用。同步方法调用一开始,调用者必须等待被调用的方法结束后,调用者后面的代码才能执行。而异步调用,指的是,调用者不用管被调用方法是否完成,都会继续执行后面的代码,当被调用的方法完成后会通知调用者。比如,在超市购物,如果一件物品没了,你得等仓库人员跟你调货,直到仓库人员给你把货物送过来,你才能继续去收银台付款,这就类似同步调用。而异步调用了,就像网购,你在网上付款下单后,什么事就不用管了,该干嘛就干嘛去了,当货物到达后你收到通知去取就好。
并发与并行
并发和并行是十分容易混淆的概念。并发指的是多个任务交替进行,而并行则是指真正意义上的“同时进行”。实际上,如果系统内只有一个CPU,而使用多线程时,那么真实系统环境下不能并行,只能通过切换时间片的方式交替进行,而成为并发执行任务。真正的并行也只能出现在拥有多个CPU的系统中。
阻塞和非阻塞
阻塞和非阻塞通常用来形容多线程间的相互影响,比如一个线程占有了临界区资源,那么其他线程需要这个资源,就必须进行等待该资源的释放,会导致等待的线程挂起,这种情况就是阻塞,而非阻塞就恰好相反,它强调没有一个线程可以阻塞其他线程,所有的线程都会尝试地往前运行。
临界区
临界区用来表示一种公共资源或者说是共享数据,可以被多个线程使用。但是每个线程使用时,一旦临界区资源被一个线程占有,那么其他线程必须等待。