【个人学习】 Java 并发编程1
前言
多线程可能早就听过很多次了,但是具体多线程是什么,干什么的,需要详细了解一下。
我们刚开始写的普通的程序是单线程,例如:
-
第一行:移动小球 A
-
第二行:播放背景音乐
-
第三行:移动小球 B
在单线程环境下,如果“移动小球 A”是一个需要运行 10 秒的死循环,那么背景音乐永远不会响,小球 B 也会纹丝不动。这叫阻塞。
他是怎么做到这三件事情同时运行的呢?这就是多线程的作用。
1.多线程--进程与线程
我们要问:计算机是如何同时运行多个程序的?
想象你的电脑是一个巨大的工业园区。每一个运行中的软件(如 IDE、浏览器)都是园区里的一家工厂。在操作系统层面,这被称为进程(Process)。每个工厂都有自己独立的电力系统、地皮和仓库(内存空间),互不干扰。
那么,工厂内部是如何工作的呢?
工厂里的一名名工人,就是线程(Thread)。所有工人共享这个工厂的原材料和空间,但每个工人同时只能专注做一件事情。
为什么要有多线程?
如果工厂只有一个工人,当他需要等待快递员送货(网络 I/O)时,整个工厂就停工了。但如果有多个工人,一个人在等货,其他人可以继续切菜或打扫。这就是多线程的核心意义:充分利用 CPU 资源,不让昂贵的处理器处于闲置状态。
2.线程安全问题
多线程带来了效率,也带来了混乱。
矛盾点在哪?
由于线程(工人)共享进程(工厂)的资源,当两个工人同时冲向同一个账本去写字时,灾难就发生了。
观察一个著名的现象:原子性缺失。
假设有一个变量 count = 0。线程 A 和线程 B 同时执行 count++。
在底层,count++ 并不是一步完成的,它分为三步:
-
读取 count 的值;
-
将值加 1;
-
将结果写回。
如果 A 读到了 0,还没来得及加 1,B 也读到了 0。结果是 A 写回了 1,B 也写回了 1。原本应该等于 2 的结果,现在变成了 1。
这就是线程不安全。为了解决它,我们需要引入“秩序”。
3.锁
如何保证账本不会被写乱?最直观的办法是加锁。
1. synchronized:Java 的内置“自动锁”
synchronized 是 Java 最基础的方案。它就像是在操作间门口加了一把感应锁:当一个工人进去后,门自动锁上,其他人在外面排队。只有里面的工人出来了,下一个人才能进去。它简单、安全,但不够灵活。
package com;
public class Text {
private int count = 0; // 共享的账本
public void add() {
count = count + 1;
}
public static void main(String[] args) throws InterruptedException {
Text shop = new Text();
// 两个工人,每人负责往账本上加 10000 次
Thread workerA = new Thread(() -> {
for (int i = 0; i < 10000; i++) shop.add();
});
Thread workerB = new Thread(() -> {
for (int i = 0; i < 10000; i++) shop.add();
});
workerA.start();
workerB.start();
// 等两个工人都干完活
workerA.join();
workerB.join();
// 理论上应该是 20000,但实际运行结果通常会小于 20000
System.out.println("理论值应该是:20000");
System.out.println("实际运行结果:" + shop.count);
}
}

加上synchronized后:
public synchronized void add() {
count = count + 1;
}

2. ReentrantLock:更高级的“手动锁”
有时候,我们希望锁能更智能。比如:
-
“如果锁被占用了,我等 5 秒钟,等不到我就去干别的。”(tryLock)
-
“我想让读账本的人可以同时进去,但写账本的人必须独占。”(ReadWriteLock)
这就是 java.util.concurrent.locks 包提供的能力,它让开发者对锁有了精细化的控制。
private final ReentrantLock lock = new ReentrantLock();
public void smartAdd() {
// 尝试拿锁,拿不到就算了,不等了
if (lock.tryLock()) {
try {
count++;
System.out.println(Thread.currentThread().getName() + " 抢到锁并加了1");
} finally {
lock.unlock();
}
} else {
// 没抢到锁,我不傻等,先去干别的
System.out.println(Thread.currentThread().getName() + " 没抢到锁,先去喝杯咖啡");
}
}
4.线程池 (Thread Pool)
当我们的工厂规模变大,新的问题出现了:雇佣和解雇工人的成本太高了。
在 Java 中,创建一个线程需要向操作系统申请资源,这是很重的操作。如果我们为每个小任务都创建一个新线程,任务结束后再销毁,CPU 的大量时间都会浪费在“招人”和“开除人”上。
线程池的逻辑:
我们预先招好 5 个核心员工(核心线程数 corePoolSize),让他们常驻工厂。
-
任务来了,员工去处理。
-
任务太多了,员工忙不过来,就让任务在休息室排队(工作队列 workQueue)。
-
如果休息室也挤满了,我们临时再招几个临时工(最大线程数 maximumPoolSize)。
-
如果连临时工也招满了,任务还在涌入,我们就不得不拒绝接收新任务(拒绝策略 RejectedExecutionHandler)。
public class SimplePool {
public static void main(String[] args) {
// 1. 创建一个固定只有 2 个线程的池子
ExecutorService pool = Executors.newFixedThreadPool(2);
// 2. 连续丢进去 5 个任务
for (int i = 1; i <= 5; i++) {
int taskId = i;
pool.execute(() -> {
System.out.println(Thread.currentThread().getName() + " 正在处理任务 " + taskId);
try { Thread.sleep(1000); } catch (InterruptedException e) {}
});
}
// 3. 关闭线程池
pool.shutdown();
}
}
通过线程池,Java 实现了对资源的极致复用。
5.AQS 与 CAS
锁和线程池是“产品”(手机),而 CAS 和 AQS 是“零件和组装图纸”(芯片和电路)
最底层:CAS(原子操作)
这是最微小的动作,像是一个开关。它保证了“改数字”这个动作不会出错。
中间层:AQS(排队框架)
有了开关(CAS)还不够,如果 100 个线程都在抢开关,抢不到的人怎么办?得有人管他们排队、睡觉、唤醒。这个“管家”就是 AQS。
产品层:ReentrantLock / CountDownLatch / 线程池
1. CAS (Compare And Swap)
这是现代并发编程的基石。它不加锁,而是采用“对比并交换”的策略。
工人 A 拿着计算好的结果“1”准备写回账本时,会先看一眼账本现在是不是还是他当初看到的“0”。
-
如果是 0,说明没人动过,写入成功。
-
如果不是 0,说明被别人抢先了,A 放弃写入,重新读取、重新计算。
这在 Java 中体现为 AtomicInteger 等原子类,性能极高。
2. AQS (AbstractQueuedSynchronizer)
无论是 ReentrantLock 还是 Semaphore,它们背后都有一个共同的“大脑”——AQS。
AQS 维护了一个状态位(state)和一个等待队列。
-
state = 0:房门开着。
-
state = 1:有人在里面。
-
队列:没抢到房门钥匙的人,都在队列里睡觉(挂起),等待被唤醒。
6.JMM (Java 内存模型)
为了运行速度,CPU 不会每次都直接读写内存,而是会把数据缓存在自己的 L1、L2 缓存里。
这就导致了一个诡异的问题: 线程 A 修改了变量 X,由于 X 还在 A 的 CPU 缓存里,线程 B 在另一个 CPU 核心上看到的 X 还是旧值。
Java 的应对:volatile 关键字
当你给一个变量加上 volatile,你就给了它“可见性”。它要求所有线程:
-
每次读变量必须从主内存读;
-
每次改变量必须立即刷回主内存。
它就像在工厂里装了一个巨大的实时显示屏,任何数据的变动,所有工人都立刻可见。
更多推荐

所有评论(0)