并发编程、多线程(1):基础与线程安全
并行与并发
- 并行(Parallelism):多个任务在同一时刻真正执行,通常依赖多核 CPU 或多处理器。
- 并发(Concurrency):多个任务在同一时间段内交替推进。单核 CPU 可借助时间片轮转实现;多核场景也同样存在并发。
并发描述任务组织与推进方式;并行描述是否真的同时执行。二者不互斥。
严格来说,一个 CPU 核心同一时刻通常只执行一个执行流。操作系统调度器通过时间片切换,让多个线程看起来同时运行。
进程与线程
- 进程:操作系统分配资源的基本单位;可理解为运行中的应用程序。
- 线程:CPU 调度的基本单位,是进程内的执行流。
- 同一进程的线程共享堆、方法区、文件等资源;每个线程有独立的程序计数器、虚拟机栈和本地方法栈。
协程、虚拟线程与 Goroutine
- 协程:用户态的轻量级执行单元,调度通常由语言运行时完成,切换成本低于内核线程。
- Java 平台线程:通常与 OS 线程 1:1 映射。
- Java 21 虚拟线程:由 JVM 将大量虚拟线程调度到少量载体线程上。遇到支持的阻塞 I/O 时可卸载,载体线程继续执行其他任务,适合高并发、I/O 密集型场景。
- Go Goroutine:Go 运行时调度的轻量级协程;常配合
channel通信。
go func() {
fmt.Println("Hello from Goroutine")
}()
线程安全的三个核心性质
- 原子性:操作不可分割,要么全部完成,要么不执行;例如锁、原子类可保证复合操作的原子性。
- 可见性:一个线程修改共享变量后,其他线程能及时看到结果;
volatile、锁均可提供可见性保证。 - 有序性:程序执行顺序符合预期,避免指令重排序带来错误;
volatile和锁可建立内存屏障与 happens-before 关系。
死锁、活锁、饥饿属于活跃性问题,不是“有序性”的定义。
i++ 是原子操作吗?
不是。它通常包含:读取 i、加 1、写回三个步骤;多线程下可能发生丢失更新。
Java 内存模型(JMM)
JMM 是 Java 规范定义的抽象内存模型,规定多线程如何读写共享变量,并关注可见性、有序性和原子性。
- 主内存:共享变量的逻辑存储位置。
- 工作内存:线程使用共享变量副本的抽象概念,实际可映射到寄存器、缓存等硬件层次。
线程本地缓存可减少直接访问 RAM 的开销,但也会带来可见性问题,因此需要 volatile、synchronized、Lock 等同步机制。
线程通信方式
Java:共享内存 + 同步机制
线程可通过共享对象通信。一个线程写入共享变量后,另一个线程需要在正确的同步约束下读取到更新后的值。
常见方式:
volatile:保证单个变量读写的可见性与一定的有序性;不保证i++这类复合操作原子性。synchronized:互斥访问,同时保证可见性与有序性。wait()/notify()/notifyAll():协调线程执行;调用时必须持有对象监视器。wait()会释放锁,唤醒后需重新竞争锁。Condition:配合ReentrantLock实现更灵活的等待/通知。Exchanger:两个线程在同步点交换数据。- 并发容器、阻塞队列、
CompletableFuture等高级工具。
Go:Channel 通信
Go 倡导“不要通过共享内存通信,而要通过通信共享内存”。channel 用于 Goroutine 间安全地传递数据;也可使用 mutex 保护共享状态。
线程的创建与启动
常见任务提交方式:
- 继承
Thread; - 实现
Runnable; - 实现
Callable并通过FutureTask或线程池获取结果; - 实际工程中优先使用线程池或虚拟线程执行器。
Runnable无返回值、不能直接抛受检异常。Callable<V>有返回值,可抛出异常。
为什么用 start() 而非直接调用 run()?
start() 会请求 JVM 创建并调度新线程,再由新线程执行 run();直接调用 run() 只是当前线程的一次普通同步方法调用,不会产生并发。
线程状态
Java Thread.State 包含:
NEW:已创建,未启动;RUNNABLE:可运行(含就绪与运行);BLOCKED:等待获取synchronized监视器锁;WAITING:无限期等待,如Object.wait()、Thread.join();TIMED_WAITING:限时等待,如sleep()、带超时的wait();TERMINATED:执行结束。
上下文切换与守护线程
- 上下文切换:CPU 保存当前线程状态并恢复另一线程状态的过程。频繁切换会带来调度、缓存失效等开销。
- 守护线程(Daemon):为用户线程提供后台服务的线程,如垃圾回收相关线程。所有非守护线程结束时,JVM 通常退出,不会等待守护线程完成。