JUC (Java util concurrent)
在java中并发主要是学的就是juc这三个包:
| 包名 | 作用 |
|---|---|
java.util.concurrent |
在并发编程中常用的工具类。 |
java.util.concurrent.atomic |
一套小型工具类,用于支持对单个变量进行无锁的线程安全编程。 |
java.util.concurrent.locks |
提供锁定和等待条件框架的接口与类,该框架区别于内置的锁定和等待机制。 |
基础定义
线程与进程
1. 进程(Process)
- 通俗比喻 :把操作系统比作一个大工厂,进程就是工厂里的一个个独立车间(比如 "微信车间""浏览器车间")。每个车间有自己独立的空间(内存、CPU 资源),车间之间互不干扰,要交流只能通过工厂的 "快递系统"(进程间通信 IPC)。
- 技术定义 :操作系统进行资源分配和调度的基本单位,是程序的一次运行实例。比如你双击打开微信,操作系统就会为微信创建一个进程,分配内存、CPU 时间片等资源。
- 特点:
- 独立:每个进程有自己的地址空间,进程崩溃不会影响其他进程(比如微信崩了,浏览器还能正常用);
- 开销大:创建 / 销毁 / 切换进程的资源消耗高(类比新建 / 拆除 / 切换车间的成本)。
2. 线程(Thread)
- 通俗比喻 :线程是车间里的一个个工人(比如微信车间里的 "接收消息工人""渲染界面工人""下载文件工人")。多个工人共享车间的资源(内存、工具),但各自干不同的活,切换工人的成本远低于切换车间。
- 技术定义 :进程的执行单元,是操作系统进行调度和执行的最小单位。一个进程可以包含多个线程,所有线程共享该进程的内存空间(如堆内存、全局变量),但每个线程有自己的栈内存(存储局部变量)。
- 特点:
- 共享资源:同一进程内的线程共享进程的资源(比如微信的所有线程都能访问微信的内存数据);
- 开销小:创建 / 切换线程的成本远低于进程;
- 线程崩溃可能导致整个进程崩溃(一个工人操作失误可能搞垮整个车间)。
并发与并行
1. 并发(Concurrency):「交替处理」多个任务
-
通俗场景:
你一边做饭(煮米饭),一边切菜,一边看煲汤的火候。你不是同时做这三件事,而是先看一眼火候,再切几刀菜,再去搅一下米饭 ------ 通过快速切换注意力,让外人感觉你 "同时在做所有事"。
对应到计算机:单个 CPU 核心通过「时间片轮转」快速切换执行多个线程,比如 CPU 先执行线程 A 10ms,再切到线程 B 10ms,再切到线程 C 10ms,宏观上看起来多个任务 "同时进行",但微观上是交替执行。
-
技术定义:
同一时间段内(而非同一时刻)处理多个任务的能力,核心是「任务调度」和「资源复用」,解决的是 "如何高效利用有限资源处理多个任务" 的问题。
-
典型例子:
- 单核心 CPU 运行多线程程序;
- 一个服务员同时接待多个顾客(先给 A 点单,再给 B 上餐,再给 C 结账)。
2. 并行(Parallelism):「同时处理」多个任务
-
通俗场景:
你和家人一起做饭:你切菜,爱人煮米饭,孩子看煲汤火候 ------三个人同时做三件事,每个任务都在同一时刻被推进。
对应到计算机:多个 CPU 核心(或多核处理器) 同时执行多个任务,比如 CPU 核心 1 执行线程 A,核心 2 执行线程 B,核心 3 执行线程 C,微观上就是真正的 "同时进行"。
-
技术定义:
同一时刻执行多个任务的能力,核心是「硬件并行」,解决的是 "如何利用多核资源加速任务执行" 的问题。
-
典型例子:
- 多核 CPU 运行多线程程序(每个线程跑在不同核心上);
- 工厂多条流水线同时生产不同产品。
时间片
一、核心定义(先懂 "是什么")
时间片是操作系统为就绪队列中的线程分配的「一段固定长度的 CPU 执行时间」(通常是几毫秒到几十毫秒,比如 10ms)。
- CPU 会轮流给每个就绪线程分配时间片,线程在自己的时间片内独占 CPU 执行代码;
- 一旦时间片用完,操作系统会暂停该线程(保存执行状态),把 CPU 切换给下一个线程;
- 这个 "分配时间片→执行→切换线程" 的过程,称为「时间片轮转调度」。
二、通俗比喻:食堂打饭的 "限时窗口"
把 CPU 比作食堂唯一的打饭窗口,线程是排队打饭的同学:
- 窗口规定「每人最多打饭 10 秒」(这 10 秒就是时间片);
- 同学 A 先打饭,10 秒一到,不管饭有没有打完,必须让出窗口;
- 轮到同学 B 打饭,10 秒后再切换给同学 C;
- 所有同学轮流使用窗口,直到各自的饭打完。
对应到计算机:
- 即使有 100 个线程,CPU 也能通过 "10ms / 线程" 的时间片轮转,让所有线程都有机会执行;
- 因为切换速度极快(毫秒级),人类感官上会觉得 "所有线程同时在执行"------ 这就是「并发」的底层实现。
并发初见端倪
1、线程调度问题
首先创建下面两个类:
java
package com.cc.concurrent.demo_01;
public class ThreadNew extends Thread{
private int flag;
public ThreadNew(int flag){
this.flag = flag;
}
@Override
public void run(){
if(flag == 1){
System.out.println("任务1开始执行");
}else{
System.out.println("任务2开始执行");
}
}
}
java
package com.cc.concurrent.demo_01;
import java.util.Stack;
public class Test {
public static void main(String[] args) {
ThreadNew t1 = new ThreadNew(1);
ThreadNew t2 = new ThreadNew(2);
t1.start(); // 启动线程 将当前线程加入到就绪队列当中
t2.start();
System.out.println("main线程");
}
}
解释:
首先写一个继承于Thread的类ThreadNew,定义好变量flag和构造器,并重写run方法。
之后编写测试类,创建t1和t2两个线程对象
然后执行start方法(start方法会将线程加入到就绪队列),等获得资源之后会被执行。之后打印main线程
注意:
start方法
start()方法不是立刻执行run()方法,只是 "报名参赛"(进入就绪队列),什么时候执行由 CPU 说了算;
main线程本身也是一个线程(主线程),和t1、t2一起竞争 CPU 时间片。
运行结果:
第一种:
java
main线程
任务1开始执行
任务2开始执行
第二种:
java
main线程
任务2开始执行
任务1开始执行
为什么会出现两种运行结果?
核心原因:线程调度的「随机性」 + CPU 时间片的「抢占式分配」
操作系统的线程调度采用「抢占式调度」(Java 默认):
- 就绪队列中的线程(
main、t1、t2)会抢占式争夺 CPU 时间片; - CPU 每次只给一个线程分配极短的时间片(比如 10ms),执行完后收回资源,再重新分配;
- 分配规则受操作系统、CPU 负载、线程优先级(默认相同)等多种因素影响,没有固定顺序。
在代码中,t1.start()和t2.start()只是把两个线程加入就绪队列,但:
t1不一定比t2先拿到 CPU 时间片;main线程执行打印语句的时机,也可能抢在t1/t2之前。
创建线程的方式
1. 继承Thread类(重写run()方法)
这是最基础的方式,通过继承Thread类,重写其run()方法定义线程执行逻辑。
java
// 1. 继承Thread类
class MyThread extends Thread {
// 2. 重写run()方法:线程要执行的逻辑
@Override
public void run() {
for (int i = 0; i < 5; i++) {
// Thread.currentThread().getName() 获取当前线程名
System.out.println(Thread.currentThread().getName() + " 执行:" + i);
try {
Thread.sleep(100); // 模拟耗时操作
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 测试类
public class ThreadCreateDemo {
public static void main(String[] args) {
// 3. 创建线程实例
MyThread t1 = new MyThread();
MyThread t2 = new MyThread();
// 4. 启动线程(调用start(),而非直接调用run())
t1.setName("线程1");
t2.setName("线程2");
t1.start();
t2.start();
}
}
核心特点:
- 优点:写法简单,直接继承 Thread,逻辑清晰;
- 缺点:Java 单继承限制,继承 Thread 后无法再继承其他类;线程与任务耦合(线程本身就是任务)。
2. 实现Runnable接口(重写run()方法)
解决了单继承问题,将「线程」和「任务」解耦(Runnable 只定义任务,Thread 负责执行任务),是更推荐的基础方式。
java
// 1. 实现Runnable接口(定义任务)
class MyRunnable implements Runnable {
// 2. 重写run()方法:任务逻辑
@Override
public void run() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + " 执行:" + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
// 测试类
public class RunnableCreateDemo {
public static void main(String[] args) {
// 3. 创建任务实例
MyRunnable task = new MyRunnable();
// 4. 创建线程实例,传入任务
Thread t1 = new Thread(task, "线程1");
Thread t2 = new Thread(task, "线程2");
// 5. 启动线程
t1.start();
t2.start();
}
}
核心特点:
- 优点:避免单继承限制;任务与线程解耦(同一个任务可被多个线程执行);
- 缺点:
run()方法无返回值,无法获取任务执行结果;无法抛出受检异常(只能捕获)。
此外还有两种进阶的方式,等以后补充
2、更直观展示时间片影响
现在修改ThreadNew类为:
java
package com.cc.concurrent.demo_01;
public class ThreadNew extends Thread{
private int flag;
public ThreadNew(int flag){
this.flag = flag;
}
@Override
public void run(){
if(flag == 1){
for (int i = 0; i<100; i++){
System.out.println("执行任务1");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}else{
for (int i = 0; i<100; i++){
System.out.println("执行任务2");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
}
}
解释:
在这里将原来 if 判断里的打印换成 for循环 ,并在打印一次之后线程休眠1毫秒。
运行结果:
由于篇幅问题不完全展示
java
main线程
执行任务2
执行任务1
执行任务1
执行任务2
执行任务2
执行任务1
执行任务2
执行任务1
执行任务2
执行任务1
执行任务1
执行任务2
执行任务2
执行任务1
执行任务1
执行任务2
执行任务2
执行任务1
执行任务2
执行任务1
执行任务1
可以直观地看到任务1和任务2是交替打印的,当打印出的时候就是争夺到时间片的时候。
3、并发所导致的资源覆盖
现在在上面的基础上添加``person`类:
java
package com.cc.concurrent.demo_01;
public class Person {
private int money;
public int getMoney() {
return money;
}
public void setMoney(int money) {
this.money = money;
}
}
再对ThreadNew类和Test类进行修改:
java
package com.cc.concurrent.demo_01;
public class ThreadNew extends Thread{
Person person;
private int flag;
public ThreadNew(int flag, Person person){
this.flag = flag;
this.person = person;
}
@Override
public void run(){
if(flag == 1){
for (int i = 0; i<100000; i++){
person.setMoney(person.getMoney() + 1);
}
}else{
for (int i = 0; i<100000; i++){
person.setMoney(person.getMoney() + 1);
}
}
}
}
java
package com.cc.concurrent.demo_01;
public class Test {
public static void main(String[] args) {
Person person = new Person();
ThreadNew t1 = new ThreadNew(1, person);
ThreadNew t2 = new ThreadNew(2, person);
t1.start(); // 启动线程 将当前线程加入到就绪队列当中
t2.start();
System.out.println("person money: " + person.getMoney());
}
}
运行结果:
java
person money: 0
那为什么最终的运行结果为0?
原因:
start()方法不会阻塞主线程,只是 "启动线程",主线程会继续往下执行;t1、t2从 "就绪状态" 到 "运行状态" 需要等待 CPU 调度(哪怕只有几毫秒),但主线程执行打印语句的速度极快(微秒级),所以打印时t1、t2还没开始执行run方法里的代码,自然读取到的是初始值0。
那这里就需要与引入一个新的 join方法。
修改Test类:
java
package com.cc.concurrent.demo_01;
public class Test {
public static void main(String[] args) {
Person person = new Person();
ThreadNew t1 = new ThreadNew(1, person);
ThreadNew t2 = new ThreadNew(2, person);
t1.start(); // 启动线程 将当前线程加入到就绪队列当中
t2.start();
try {
t1.join(); // 阻塞当前线程,等待t1线程执行完毕
t2.join(); // 阻塞当前线程,等待t1线程执行完毕
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("person money: " + person.getMoney());
}
}
解释:
join方法:
当在线程A中调用线程B.join()时:
线程A会立刻进入阻塞状态(暂停执行);- 直到
线程B执行完毕(进入终止状态),线程A才会从阻塞状态恢复,继续执行后续代码; - 也可以指定等待时间(如
join(1000),表示最多等 1 秒,超时后不管线程B是否执行完,线程A都会继续)。
注意:
join()的调用时机 :必须在start()之后调用(先启动线程,再等待),如果先join()再start(),join()会立刻返回(因为线程还没启动,视为已执行完毕)。join()的底层原理 :依赖Object.wait()实现(本质是等待 / 通知机制),所以调用join()时会释放线程持有的锁吗?不会 ------join()是让当前线程等待,和锁释放无关(锁释放只有synchronized代码块执行完、wait()调用时才会发生)。- 多个线程
join()的顺序 :按调用顺序等待,比如先t1.join()再t2.join(),主线程会先等 t1 执行完,再等 t2 执行完;如果想让 t1、t2 同时执行完再继续,顺序不影响。
运行结果:
java
person money: 123307
person money: 136596
person money: 119421
person money: 121239
可以发现,多次运行之后会有多个不同的结果出现
分析原因:
首先拆解person.setMoney(person.getMoney() + 1);的底层指令
看似简单的一行代码,CPU 会拆分成 3 个独立的操作(非原子):
- 读操作 :
person.getMoney()→ 从主内存读取money的当前值到线程的工作内存(比如读到100); - 改操作 :
+1→ 在线程工作内存中计算新值(100+1=101); - 写操作 :
person.setMoney(...)→ 把新值写回主内存。
这三个操作之间可以被其他线程打断,正是这种 "可打断性" 导致了数据覆盖。
这就有可能发生数据覆盖:
假设当前money=0,t1 和 t2 同时执行这行代码:
| 时间点 | t1 线程操作 | t2 线程操作 | 主内存 money 值 |
|---|---|---|---|
| 1 | 读:0 | - | 0 |
| 2 | - | 读:0 | 0 |
| 3 | 改:0+1=1 | - | 0 |
| 4 | - | 改:0+1=1 | 0 |
| 5 | 写:1 | - | 1 |
| 6 | - | 写:1 | 1 |
结果:t1 和 t2 各执行了一次 + 1,但最终 money 只从 0 变成 1(本该变成 2),一次修改被 "覆盖" 了。
当循环 10 万次时,这种覆盖会发生成千上万次,最终结果必然远小于 200000。
那如何才能解决这个问题?
就需要引入一个新概念------加锁
4、加synchronized锁
加synchronized锁是 Java 原生支持的最基础、最常用的锁,属于隐式锁(JVM 自动管理锁的获取和释放,无需手动操作)。
使用synchronized关键字是用来实现线程同步的,当多个线程同时去争抢同一个资源的时候加一个同步的关键字,能够使得线程排队去完成操作。
使用场景:
-
修饰实例方法 :锁的是当前
this对象(实例); -
修饰静态方法 :锁的是当前类的
Class对象(全局锁); -
修饰代码块:锁的是synchronized括号里配置的指定对象(灵活控制锁粒度)。
那现在就可以处理上面数据覆盖的问题了
上面的数据覆盖旨在当a线程在操作对象的时候,还没操作完b线程就拿取对象了。所以要在方法上加锁把当前操作的对象锁柱,即在a线程操作对象时,不允许其它线程操作该对象,操作完之后才能让其他线程拿取
在Test类中添加add方法:
java
public synchronized void add(Person person){
person.setMoney(person.getMoney() + 1);
}
将ThreadNew类中的person.setMoney(person.getMoney() + 1);换成person.add(person);
这样的话在线程操作Person类的时候就不会出现数据被其他线程覆盖的情况
执行结果:
java
person money: 200000
5、卖票问题
首先创建Ticket和UnsafeTicketDemo两个类:
java
package com.cc.concurrent.demo_02_ticket;
// 售票系统案例(未同步,存在并发安全问题)
public class Ticket implements Runnable {
private int ticketNum = 100; // 共享资源:100张票
@Override
public void run() {
while (true) {
if (ticketNum > 0) {
// 模拟售票延迟(放大并发问题)
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖出票" + (ticketNum--) + "张");
} else {
break;
}
}
}
}
java
package com.cc.concurrent.demo_02_ticket;
public class UnsafeTicketDemo {
public static void main(String[] args) {
Ticket ticket = new Ticket();
// 3个窗口(3个线程)同时售票
new Thread(ticket, "窗口1").start();
new Thread(ticket, "窗口2").start();
new Thread(ticket, "窗口3").start();
}
}
运行结果:
java
窗口2卖出票100张
窗口1卖出票99张
窗口3卖出票98张
窗口2卖出票97张
窗口1卖出票96张
窗口3卖出票96张
窗口2卖出票95张
窗口3卖出票94张
窗口1卖出票95张
窗口3卖出票92张
...
窗口2卖出票5张
窗口1卖出票5张
窗口3卖出票5张
窗口1卖出票4张
窗口2卖出票4张
窗口3卖出票4张
窗口3卖出票3张
窗口1卖出票1张
窗口2卖出票2张
很明显这里出现了并发的数据覆盖问题。
那应该需要用到synchronized关键字加锁了,那该怎么加锁呢?
先往Ticket类里重写的run方法 上加一下synchronized关键字
java
package com.cc.concurrent.demo_02_ticket;
// 售票系统案例(未同步,存在并发安全问题)
public class Ticket implements Runnable {
private int ticketNum = 100; // 共享资源:100张票
@Override
public synchronized void run() //加锁 {
while (true) {
if (ticketNum > 0) {
// 模拟售票延迟(放大并发问题)
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖出票" + (ticketNum--) + "张");
} else {
break;
}
}
}
}
运行结果:
java
窗口1卖出票100张
窗口1卖出票99张
窗口1卖出票98张
窗口1卖出票97张
窗口1卖出票96张
窗口1卖出票95张
窗口1卖出票94张
窗口1卖出票93张
窗口1卖出票92张
...
窗口1卖出票8张
窗口1卖出票7张
窗口1卖出票6张
窗口1卖出票5张
窗口1卖出票4张
窗口1卖出票3张
窗口1卖出票2张
窗口1卖出票1张
现在倒是没有出现数据覆盖了,但是为什么所有的票都是窗口1卖出的呢?
因为synchronized 锁的粒度太大,给 run() 方法加了 synchronized这会导致:
- 锁对象是当前 Ticket 实例 (三个线程共享同一个
ticket对象) - 一旦某个线程(比如窗口 1)抢到锁,就会进入
while(true)循环,一直执行到票卖完才释放锁 - 其他线程(窗口 2、窗口 3)只能一直等待,根本抢不到锁,所以看起来全是窗口 1 在卖票
那现在就要只给操作共享资源(ticketNum)的核心代码块 加锁,而不是锁整个 run() 方法:
java
@Override
public void run() {
while (true) {
// 只锁修改票号的这一段代码,让线程有机会切换
synchronized (this) {
if (ticketNum > 0) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "卖出票" + (ticketNum--) + "张");
} else {
break;
}
}
// 锁释放后,其他线程有机会抢到锁
try {
Thread.sleep(1); // 微小延迟,让线程调度更公平
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
运行结果:
java
窗口1卖出票100张
窗口3卖出票99张
窗口2卖出票98张
窗口3卖出票97张
窗口1卖出票96张
窗口3卖出票95张
窗口2卖出票94张
窗口3卖出票93张
窗口1卖出票92张
窗口3卖出票91张
...
窗口3卖出票9张
窗口1卖出票8张
窗口3卖出票7张
窗口2卖出票6张
窗口3卖出票5张
窗口1卖出票4张
窗口3卖出票3张
窗口2卖出票2张
窗口3卖出票1张
为什么这样改就对了?
- 锁只包裹判断票数 + 卖票的核心逻辑,执行完就释放锁
- 锁释放后,三个线程重新争抢,窗口 1、窗口 2、窗口 3 都有机会执行
- 最终会看到三个窗口交替卖票,且不会出现超卖 / 重卖问题
补充:为什么经常是窗口 1 先抢到锁?
线程启动后,谁先抢到锁是操作系统调度决定的 ,但窗口 1 先启动,通常会先抢到锁,加上 while(true) 独占执行,就会出现你看到的 "全是窗口 1 卖票" 的现象。
