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线程本身也是一个线程(主线程),和t1t2一起竞争 CPU 时间片。

运行结果

第一种:

java 复制代码
main线程
任务1开始执行
任务2开始执行

第二种:

java 复制代码
main线程
任务2开始执行
任务1开始执行

为什么会出现两种运行结果?

核心原因:线程调度的「随机性」 + CPU 时间片的「抢占式分配」

操作系统的线程调度采用「抢占式调度」(Java 默认):

  • 就绪队列中的线程(maint1t2)会抢占式争夺 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()方法不会阻塞主线程,只是 "启动线程",主线程会继续往下执行;
  • t1t2从 "就绪状态" 到 "运行状态" 需要等待 CPU 调度(哪怕只有几毫秒),但主线程执行打印语句的速度极快(微秒级),所以打印时t1t2还没开始执行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、卖票问题

首先创建TicketUnsafeTicketDemo两个类:

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 卖票" 的现象。

相关推荐
coder_zh_2 小时前
Java基础-学习-面试-校招-要点突击检查
java
郑州光合科技余经理2 小时前
海外O2O系统源码剖析:多语言、多货币架构设计与二次开发实践
java·开发语言·前端·小程序·系统架构·uni-app·php
前端摸鱼匠8 小时前
【AI大模型春招面试题11】什么是模型的“涌现能力”(Emergent Ability)?出现条件是什么?
人工智能·算法·ai·自然语言处理·面试·职场和发展
工程师老罗8 小时前
Image(图像)的用法
java·前端·javascript
leo_messi948 小时前
2026版商城项目(一)
java·elasticsearch·k8s·springcloud
globaldomain8 小时前
什么是用于长距离高速传输的TCP窗口扩展?
开发语言·网络·php
美味蛋炒饭.8 小时前
Tomcat 超详细入门教程(安装 + 目录 + 配置 + 部署 + 排错)
java·tomcat
沈阳信息学奥赛培训8 小时前
#undef 指令 (C/C++)
c语言·开发语言·c++
2401_873204658 小时前
分布式系统安全通信
开发语言·c++·算法