多线程基础——构建高并发应用必会
基本概念
先来捋一捋并行编程里那些绕不开的基础概念。很多初学者一上来就被术语搞晕,其实拆开看,没想象中那么玄乎。
同步(Synchronous)和异步(Asynchronous)
同步
异步

并发(Concurrency)和并行(Parallelism)
并发
并行
临界区
所谓临界区,就是一块被多个线程共享的资源,比如一个计数器、一个共享列表。规矩只有一个:同一时刻只能有一个线程进去操作。其他线程如果也想用,就得乖乖排队等着。
在并行程序里,保护临界区资源是头等大事。要是没保护好的话,数据就乱套了。
阻塞(Blocking)和非阻塞(Non-Blocking)
这两个词描述的是线程之间的“干扰度”。
阻塞
synchronized 或可重入锁。
非阻塞
死锁(DeadLock)、饥饿(Starvation)和活锁(LiveLock)
这三兄弟都属于“活跃性”问题——说白了就是线程能不能正常跑下去。
死锁
饥饿
活锁
并发级别
阻塞(Blocking)
前面说过,阻塞就是线程被挂起直到资源释放。比如用 synchronized 或可重入锁。缺点很明显——容易饿死人。
无饥饿(Starvation-Free)
用公平锁就能解决饥饿问题——按顺序来,谁先排队谁先得。但本质上还是阻塞策略,只是让排队更公平。
无障碍(Obstruction-Free)
这是最弱的非阻塞方案:线程可以自由进入临界区,但一旦发现有别人同时在改数据,就立刻回滚重试。一种常见实现是借助“一致性标记”——线程操作前先记下标记,操作完再检查标记有没有变,变了就重来。
问题也很明显:冲突一多,大家不断回滚,谁都没法正常走完。
无锁(Lock-Free)
在无障碍的基础上加了一条规定:必须保证至少有一个线程能在有限步数内成功退出。常见的CAS循环就是典型例子:
// 如果修改不成功,那么循环永远不会停止
while(!atomicVar.compareAndSet(localVar, localVar + 1)){
localVar = atomicVar.get();
}
但这里也有隐患——如果CAS一直失败,某些线程可能永远循环下去,类似于饥饿。
无等待(Wait-Free)
在无锁的基础上再加码:所有线程都必须在有限步数内完成,彻底杜绝饥饿。如果限制步数上限,还可以分出“有界无等待”和“线程数无关的无等待”。
典型的例子是RCU(Read-Copy-Update):读线程完全不受限制,写线程先复制一份数据副本,在副本上修改,等时机成熟了再整体替换。
Amdahl定律
加速比 = 优化前耗时 / 优化后耗时。公式如下:
Tn = T1(F + (1-F)/n) = 1 / (F + (1-F)/n)
其中n是CPU数量,F是程序中串行执行的比例。结论很明确:即使CPU无限多,加速比也受限于串行比例F。想跑得更快,光加CPU是不够的,关键是要把并行比例拉上去。
Gustafson定律
换个角度看:串行时间 = a,并行时间 = b,总时间 = a + b,串行比例 F = a / (a + b)。那么:
S(n) = (a + nb) / (a + b) = F + n(1-F)
结论:如果串行比例很小,加速比基本就等于处理器个数。只要持续堆CPU,速度就能线性增长。
两个定律没啥矛盾,只是视角不同:Amdahl强调的是在串行比例固定时,加速比有天花板;Gustafson则强调,只要并行比例足够大,堆CPU就很有效。串行比例为1时,怎么搞加速比都是1;串行比例为0时,加速比就是n。
JMM(Ja va内存模型)
JMM就是一套规则,保证多个线程能有效、正确地配合工作。核心围绕着三个特性——原子性、可见性、有序性来展开。
原子性
指一个操作一旦开始,就不会被其他线程打断,整体不可拆分。举个反例:在32位系统上,long型数据的读写就不是原子性的——因为long占64位,一次读写可能只改了半截。
可见性
当一个线程修改了共享变量,其他线程能不能立即感知到?不一定。缓存优化、编译器优化、硬件优化、指令重排……这些都可能让修改“延迟”甚至“失效”。
有序性
为了提高性能,CPU可能会打乱指令的执行顺序——这叫指令重排。重排之后,单线程内的逻辑不会出错(遵守Happen-Before规则),但在多线程环境下,可能就乱套了。
// 如果不加volatile,线程ReadT已经读到ready为false,
// 当主线程修改ready为true后,ReadT并不知道,将一直循环下去。
private static volatile boolean ready;
private static int number = 0;
public static class ReadT extends Thread {
@Override
public void run() {
while (!ready);
System.out.println(number);
}
}
public static void main(String[] args) throws InterruptedException {
new ReadT().start();
Thread.sleep(1000);
number = 42;
ready = true;
}
volatile 能保证可见性,但不能保证原子性。它能禁止指令重排序(保证一定的有序性),但仅限于它修饰的变量前后。而 synchronized 则能同时保证原子性、可见性和有序性。
Happen-Before原则
这是指令重排必须遵守的“底线规则”,确保重排后单线程语义不变:
- :一个线程内,代码的语义是串行一致的。
程序顺序原则
- :对volatile变量的写操作,先于后续的读操作,保障可见性。
volatile规则
- :解锁必然先于后续的加锁。
锁规则
- :A先于B,B先于C,那么A必然先于C。
传递性
- :线程的start()方法先于该线程的任何动作。
线程start()规则
- :线程的所有写操作先于后续其他线程的读操作。
线程写操作规则
- :线程的所有操作先于线程的终结(Thread.join()返回)。
线程join()规则
- :对线程的中断调用先于被中断线程检测到中断事件。
线程interrupt()规则
- :构造函数的执行、结束先于finalize()方法。
对象构造规则