首页 > 教程攻略 > ai教程 >多线程基础——构建高并发应用必会

多线程基础——构建高并发应用必会

来源:互联网 时间:2026-06-15 07:39:54

基本概念

先来捋一捋并行编程里那些绕不开的基础概念。很多初学者一上来就被术语搞晕,其实拆开看,没想象中那么玄乎。

同步(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()方法。