java并发——1

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 比作食堂唯一的打饭窗口,线程是排队打饭的同学:

  1. 窗口规定「每人最多打饭 10 秒」(这 10 秒就是时间片);
  2. 同学 A 先打饭,10 秒一到,不管饭有没有打完,必须让出窗口;
  3. 轮到同学 B 打饭,10 秒后再切换给同学 C;
  4. 所有同学轮流使用窗口,直到各自的饭打完。

对应到计算机:

  • 即使有 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都会继续)。

注意:

  1. join()的调用时机 :必须在start()之后调用(先启动线程,再等待),如果先join()再start(),join()会立刻返回(因为线程还没启动,视为已执行完毕)。
  2. join()的底层原理 :依赖Object.wait()实现(本质是等待 / 通知机制),所以调用join()时会释放线程持有的锁吗?不会 ------join()是让当前线程等待,和锁释放无关(锁释放只有synchronized代码块执行完、wait()调用时才会发生)。
  3. 多个线程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 个独立的操作(非原子):

  1. 读操作 :person.getMoney() → 从主内存读取money的当前值到线程的工作内存(比如读到100);
  2. 改操作 :+1 → 在线程工作内存中计算新值(100+1=101);
  3. 写操作 :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这会导致:

  1. 锁对象是当前 Ticket 实例 (三个线程共享同一个 ticket 对象)
  2. 一旦某个线程(比如窗口 1)抢到锁,就会进入 while(true) 循环,一直执行到票卖完才释放锁
  3. 其他线程(窗口 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 卖票" 的现象。

相关推荐
弹简特2 小时前
【Java项目-企悦抽】15-活动管理模块02-活动创建测试与活动列表实现
java·开发语言·springboot
斯内普吖2 小时前
(开源)水果蔬菜商城实战指南 基于 Java + SSM + Vue + MySQL
java·vue.js·mysql·开源
wang_shu_mo_ran2 小时前
Spring IoC和DI概念篇
java·后端·spring
小狼154542 小时前
拼多多订单数据导出 CSV 实战:接口分页、字段平铺与 5 个数据坑,多多开票助手
java·前端·javascript
Joe_Wang52 小时前
【从0到1学习JVM · 25】同样都要停顿所有线程,Parallel比Serial到底强在哪
java·jvm·学习·垃圾回收
wuminyu2 小时前
JVM虚拟线程的底层实现原理分析
java·linux·c语言·jvm·c++
keyipatience2 小时前
跳表详细解析
开发语言·数据结构·c++·算法·跳表
喆星时瑜2 小时前
R语言基础语法入门
开发语言·r语言
想吃火锅10052 小时前
【leetcode】189.轮转数组js
开发语言·javascript·leetcode
郝学胜-神的一滴2 小时前
AI 编程智能体 02:AI智能体到底是什么
开发语言·人工智能·python·程序人生·pycharm