多线程基础深度教程:高并发应用开发必备技能与实战案例
基本概念
先来捋一捋并行编程里那些绕不开的基础概念。很多初学者一上来就被术语搞晕,其实拆开看,没想象中那么玄乎。
同步(Synchronous)和异步(Asynchronous)
同步:调用方发起调用后,必须眼巴巴地等结果回来,才能继续往下走。打个比方——你打电话订外卖,对方说“请稍等”,你就得握着电话等,直到他告诉你“好了”才能挂断去做别的事。
异步:调用方把请求丢出去,扭头就走,不用等结果。还是外卖的例子——你在App上下了单,该刷剧刷剧,该打游戏打游戏,等外卖到了,自然会收到通知。一个要等,一个不等,就这么简单。
并发(Concurrency)和并行(Parallelism)
并发:多个任务在宏观上看起来是同时进行的,但微观上它们是在交替执行——就像一个人同时管着三个水壶,一会儿看看这个,一会儿看看那个。
并行:这才是真正意义上的“同时进行”。只有多核或多CPU系统才能做到,比如你左手写代码、右手吃零食,两件事真·同时发生。
临界区
所谓临界区,就是一块被多个线程共享的资源,比如一个计数器、一个共享列表。规矩只有一个:同一时刻只能有一个线程进去操作。其他线程如果也想用,就得乖乖排队等着。
在并行程序里,保护临界区资源是头等大事。要是没保护好的话,数据就乱套了。
阻塞(Blocking)和非阻塞(Non-Blocking)
这两个词描述的是线程之间的“干扰度”。
阻塞:如果一个线程占住了临界区,那么其他想用这块资源的线程都会被挂起来,动弹不得。典型例子用 synchronized 或可重入锁。
非阻塞:反其道而行——没有哪个线程能挡住别人前进的脚步。所有线程都在努力往前走,谁也不让谁。
死锁(DeadLock)、饥饿(Starvation)和活锁(LiveLock)
这三兄弟都属于“活跃性”问题——说白了就是线程能不能正常跑下去。
死锁:线程A拿着锁1等锁2,线程B拿着锁2等锁1,两个人互相眼巴巴等着,谁也动不了。
饥饿:一个或者多个线程因为优先级太低,或者资源一直被高优先级线程占着,导致长期得不到执行机会——就像食堂打饭,总有人插队,老实人一直吃不上。
活锁:线程们太“礼貌”了,互相让路,结果资源在两个线程之间弹来弹去,谁都没法真正拿到所有资源完成工作。
并发级别
阻塞(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()方法先于该线程的任何动作。
- 线程写操作规则:线程的所有写操作先于后续其他线程的读操作。
- 线程join()规则:线程的所有操作先于线程的终结(Thread.join()返回)。
- 线程interrupt()规则:对线程的中断调用先于被中断线程检测到中断事件。
- 对象构造规则:构造函数的执行、结束先于finalize()方法。
