在这里插入图片描述

线程状态

大致划分

  • 线程的状态,大致分为就绪堵塞两个部分。但是具体的分类,操作系统和java有两套不同的划分体系。
  • 我们大致先了解一下Java体系下对于线程状态的划分情况

4种状态的划分

NEW
  • 只是安排了工作但是没用开始行动
RUNNABLE
  • 正在被服务的线程
WAITING TIMED_WAITING BLOCKED
  • 这几个都是阻塞
    • waiting是join()引起的阻塞
    • timed_waiting是sleep引起的阻塞或者join()超过时间引起的阻塞
    • blocked是由于锁竞争引起的阻塞

观察线程的状态和转移

案例一
public class Main {
    public static void main(String[] args) {
        Thread t=new Thread(()->{
           for (int i = 0; i < 1000; i++) {

           }

        }, "子线程");
				//启动前
        System.out.println(t.getName()+":"+t.getState());
        //启动
        t.start();
        System.out.println(t.getName()+":"+t.getState());
        //一直执行直到t中循环完毕
        while(t.isAlive()){
            System.out.println(t.getName()+":"+t.getState());
        }
        //线程终止后
        System.out.println(t.getName()+":"+t.getState());
    }
}

打印结果
在这里插入图片描述

1. 类和主方法声明
public class Main {
    public static void main(String[] args) {
        // 核心逻辑都在这个主方法中,main方法本身对应一个「主线程」
    }
}

Java程序运行时,会先启动main方法对应的主线程,所有代码都在主线程中开始执行。

2. 创建子线程对象
Thread t=new Thread(()->{
   for (int i = 0; i < 1000; i++) {
       // 空循环:仅仅是让子线程有一定的执行耗时,方便观察状态变化
       // 没有实际业务逻辑,只是为了不让子线程瞬间执行完毕
   }
}, "子线程");

这是Java 8及以上的Lambda表达式写法(用来简化 Runnable 接口的实现),等价于创建一个实现了Runnable接口的线程任务。

  • Thread t:声明一个线程对象,变量名为t
  • ()->{...}:Lambda表达式,代表线程要执行的「任务体」(也就是子线程启动后会运行的代码),这里是一个循环1000次的空循环。
  • "子线程":给这个线程指定一个名称,方便后续识别和打印。
  • 此时仅仅是创建了线程对象,子线程还没有开始运行,只是一个「待启动」的对象。
3. 打印线程状态(启动前)
System.out.println(t.getName()+":"+t.getState());
  • t.getName():获取线程名称(就是我们创建时指定的「子线程」)。
  • t.getState():获取线程当前的状态,返回一个Thread.State枚举类型的值。
  • 此时线程还未调用start()方法,状态是**NEW**(新建状态),这是线程的初始状态。
4. 启动子线程
t.start();

这是启动线程的关键方法,注意:

  • 调用start()方法,并不是立即执行子线程的任务体,而是告诉Java虚拟机「这个线程可以被调度执行了」,具体什么时候执行由操作系统的线程调度器决定。
  • 一个线程只能调用一次start()方法,多次调用会抛出IllegalThreadStateException异常。
5. 打印线程状态(启动后立即打印)
System.out.println(t.getName()+":"+t.getState());

调用start()后立即打印,此时子线程大概率已经被调度器标记为「可运行」,状态通常是**RUNNABLE**(可运行状态)。

  • 注意:RUNNABLE状态包含两种情况:① 线程正在CPU上执行;② 线程已经准备就绪,等待被CPU调度执行(因为CPU资源有限,同一时间只能执行少量线程)。
  • 由于主线程和子线程是并发执行的,这一步也有可能偶尔打印出其他状态(但大概率是RUNNABLE)。
6. 循环打印存活状态下的线程状态
while(t.isAlive()){
    System.out.println(t.getName()+":"+t.getState());
}
  • t.isAlive():判断线程是否处于「存活状态」,返回boolean值。存活状态的定义是:线程已经调用start()方法,且尚未执行完毕(未进入终止状态)。
  • 这个while循环会一直执行,直到子线程执行完毕(空循环1000次结束),退出循环。
  • 循环内部打印的线程状态,绝大多数情况下是**RUNNABLE**(因为子线程在执行空循环,要么正在运行,要么等待调度)。
  • 由于线程调度的不确定性,偶尔可能会出现其他状态(但对于这个简单的空循环任务,几乎不会出现)。
7. 打印线程最终状态
System.out.println(t.getName()+":"+t.getState());

while循环退出时,说明t.isAlive()返回false,子线程已经执行完毕,此时线程的状态是**TERMINATED**(终止状态),这是线程的最终状态。

  • 线程进入TERMINATED状态后,就无法再被启动,也无法再改变状态。
总结
  1. 这段代码的核心是跟踪并打印单个子线程从创建到终止的状态变化,关键方法有getName()getState()start()isAlive()
  2. 线程的核心生命周期状态流转:NEW(新建)→ RUNNABLE(可运行)→ TERMINATED(终止)(无阻塞场景下)。
  3. start()方法是启动线程的唯一合法方式,仅负责将线程纳入调度队列,不保证立即执行。
案例二
public class Main {
    public static void main(String[] args) {
        final Object object = new Object();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    while (true) {
                        try {
			                      Thread.sleep(1000);                     
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }, "t1");
        t1.start();
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("hehe");
                }
            }
        }, "t2");
        t2.start();
    }
}

无输出
无输出

1. 全局变量与主程序初始化
// 定义一个最终的Object对象,作为两个线程的同步锁
final Object object = new Object();
  • final 修饰符:保证object引用不会被修改(不能再指向其他新的Object对象),这是匿名内部类访问外部变量的要求(Java 8+ 虽可省略final,但变量仍需是"事实上的不可变")。
  • 这里的object共享锁对象,用于后续两个线程的同步竞争,是实现线程互斥的核心。
2. 线程t1的创建与执行逻辑
Thread t1 = new Thread(new Runnable() {
    @Override
    public void run() {
        // 加锁:获取object对象的内置锁(监视器锁)
        synchronized (object) {
            // 无限循环,没有退出条件
            while (true) {
                try {
                    // 线程休眠1000毫秒(1秒)
                    Thread.sleep(1000);                     
                } catch (InterruptedException e) {
                    // 捕获中断异常并打印堆栈信息
                    e.printStackTrace();
                }
            }
        }
    }
}, "t1");
t1.start(); // 启动t1线程
  • new Thread(Runnable, String):创建线程,第二个参数是线程名称(方便调试)。
  • synchronized (object)同步代码块,线程执行到这里时,必须先获取object对象的「内置锁」(也叫监视器锁),才能进入代码块内部执行。
  • while (true):无限循环,没有任何退出条件,意味着一旦进入这个循环,就会一直执行下去。
  • Thread.sleep(1000):让当前线程休眠1秒,注意:休眠期间,线程不会释放已经获取到的object,这是这段代码的关键要点之一。
  • t1.start():启动t1线程,JVM会调用该线程的run()方法。
3. 线程t2的创建与执行逻辑
Thread t2 = new Thread(new Runnable() {
    @Override
    public void run() {
        // 加锁:尝试获取object对象的内置锁
        synchronized (object) {
            System.out.println("hehe");
        }
    }
}, "t2");
t2.start(); // 启动t2线程
  • 逻辑和t1类似,核心是synchonized (object):尝试获取object的内置锁。
  • 但由于t1已经先启动,并且在synchronized代码块内无限循环(还不释放锁),t2永远无法获取到object锁,因此永远无法进入同步代码块执行System.out.println("hehe")
  • t2会一直处于「阻塞状态」(更准确地说是「锁等待状态」),直到object锁被释放,但这段代码中t1永远不会释放锁,所以t2的目标输出永远无法实现。

关键细节补充

  1. synchronized锁的特性
    • 互斥性:同一时间,只有一个线程能获取到同一个对象的内置锁,其他线程尝试获取会被阻塞。
    • 可重入性:同一个线程可以多次获取同一个对象的内置锁(比如同步方法调用另一个同步方法),不会造成死锁。
  2. Thread.sleep()与锁的关系
    • sleep()方法只会让线程暂停执行指定时间,不会释放任何已持有的锁
    • 与之相对的,object.wait()方法会让线程进入等待状态,并且会主动释放持有的锁,这是两者的核心区别。
  3. 线程的执行顺序
    • t1.start()先执行,不代表t1一定会先获取到锁(线程启动有微小的时间差),但由于t1获取锁后是无限循环,即使偶尔t2先启动,最终结果也是一样的——t2永远无法执行输出语句。
public class Main {
    public static void main(String[] args) {
        final Object object = new Object();
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    while (true) {
                        try {
//                            Thread.sleep(1000);
                            object.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }, "t1");
        t1.start();
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (object) {
                    System.out.println("hehe");
                }
            }
        }, "t2");
        t2.start();
    }
}

在这里插入图片描述

总结
  1. 这段代码的核心是通过synchronized (object)实现两个线程的锁竞争,object是共享锁对象。
  2. 线程t1获取锁后进入无限循环并休眠,且休眠期间不释放锁,导致线程t2永远无法获取锁。
  3. 最终结果是t1无限休眠,t2一直阻塞等待锁,"hehe"永远无法被输出。
  4. 关键知识点:synchronized的互斥性、Thread.sleep()不释放锁、final修饰共享锁对象的作用。

线程安全

引例

  • 先看一个例子
// 此处定义一个 int 类型的变量
private static int count = 0;
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        // 对 count 变量进行自增 5w 次
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    Thread t2 = new Thread(() -> {
        // 对 count 变量进行自增 5w 次
        for (int i = 0; i < 50000; i++) {
            count++;
        }
    });
    t1.start();
    t2.start();
    // 如果没有这俩 join, 肯定不行的. 线程还没自增完, 就开始打印了. 很可能打印出来的count 就是个 0
    
    t1.join();
    t2.join();
    // 预期结果应该是 10w
    System.out.println("count: " + count);
}

  • 实则每次运行的结果不一样(都比10w小):在这里插入图片描述在这里插入图片描述在这里插入图片描述
  • 可以发现,在多线程中的运算结果都不一致,这样具有不确定性,肯定不是我们想要的,是不安全的。所以我们可以这么理解:如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线程安全的。

线程不安全的原因

线程的调度是随机的

  • 随机调度使一个程序在多线程环境下, 执行顺序存在很多的变数.

修改共享数据

  • 上述例子中,是两个线程针对一个count变量进行操作
  • 在这里插入图片描述

原子性

  • 我们把一段代码想象成一个房间,每个线程就是要进入这个房间的人。如果没有任何机制保证,A进入房间之后,还没有出来;B 是不是也可以进入房间,打断 A 在房间里的隐私。这个就是不具备原子性的。
    那我们应该如何解决这个问题呢?是不是只要给房间加一把锁,A 进去就把门锁上,其他人是不是就进不来了。这样就保证了这段代码的原子性了。
    有时也把这个现象叫做同步互斥,表示操作是互相排斥的。
一条Java语句不一定是原子的
  • 其实刚才是学习C语言也和例子中的count++一样,是一条Java语句(或者别的语言的语句),它其实是由3步构成的:
    1. LOAD:从内存把数据取到CPU
    2. ADD:进行数据更新
    3. SAVE:把数据回CPU
  • 所以上面这样一个语句不是原子的。那么,不是原子的会出现什么问题呢?
    • 原本在单线程中,这三个步骤是按顺序执行的。但是在多线程当中,如果一个线程对于一个变量进行操作,中途有其他线程插了进来,如果这个线程被打断了,结果就可能是错误的。这点也和线程的抢占式调度密切相关. 如果线程不是 “抢占” 的, 就算没有原子性, 也问题不大.
    • 此时引入了两个问题:
  • 为啥要整这么多内存?
    • 实际并没有这么多 “内存”. 这只是 Java 规范中的一个术语, 是属于 “抽象” 的叫法.
      所谓的 “主内存” 才是真正硬件角度的 “内存”. 而所谓的 “工作内存”, 则是指 CPU 的寄存器和高速缓存.
  • 为啥要这么麻烦的拷来拷去?
    • 因为 CPU 访问自身寄存器的速度以及高速缓存的速度, 远远超过访问内存的速度(快了 3 - 4 个数量级,也就是几千倍, 上万倍).比如某个代码中要连续 10 次读取某个变量的值, 如果 10 次都从内存读, 速度是很慢的. 但是如果只是第一次从内存读, 读到的结果缓存到 CPU 的某个寄存器中, 那么后 9 次读数据就不必直接访问内存了.效率就大 提高了.

可见性

  • 可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到.

Java 内存模型 (JMM)

  • Java虚拟机规范中定义了Java内存模型.
    目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果.
    在这里插入图片描述
    • 线程之间的共享变量存在 主内存 (Main Memory).
    • 每一个线程都有自己的 “工作内存” (Working Memory) .
    • 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据.
    • 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.
    由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 “副本”. 此时修改线
    程1 的工作内存中的值, 线程2 的工作内存不一定会及时变化.
步骤可能性解释
  • 对于count++操作来说,如果按理想状态如下图,没有任何问题
    在这里插入图片描述
    在这里插入图片描述
    但是,只要顺序有一点改变,结果可能就不同了:
    在这里插入图片描述
  • 如果按上面这种执行顺序,那么运行的最终结果就是count=1
  • 在这里插入图片描述

线程不安全原因的总结

  1. 【根本】线程的调度是随机的,一个线程执行到任何一个指令都可能从cpu上被调走
  2. 多个线程同时修改同一个变量
    • 1️⃣不是同时,一个线程操作,一个线程操作=>不会产生问题
    • 2️⃣多个线程同时读取这个变量=>没问题
    • 3️⃣多个线程同时修改不同变量=>没问题
  3. 【直接原因】针对变量的修改不是原子的 (ex.count++)
  4. 内存可见性引起的线程安全问题(后面说)
  5. 指令重排序引起的线程安全问题(后面说)
  • 线程安全性问题,很多时候就是概率出现的问题,概率性问题非常不好处理,所以写的时候就要注意
  • 对于引例给出一种解决方案:
public class Demo16_线程安全问题 {
    private  static int count = 0,count2 = 0;
    public static void main(String[] args) throws InterruptedException {
        //创建两个线程,针对同一个变量进行循环自增

        //定义一个锁对象,可以是任意的对象
        Object locker = new Object();

        Thread t1 = new Thread(()->{
//            try {
//                Thread.sleep(1000);
//            } catch (InterruptedException e) {
//                throw new RuntimeException(e);
//            }
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
        });

        Thread t2 = new Thread(()->{
            for(int i=0;i<50000;i++){
                synchronized (locker){
                    count++;
                }
            }
        });

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + (count+count2));

    }
}

解决线程不安全的方案

synchronized 关键字

  • 就如上面让count++的三步,我们让不是原子操作的步骤给他“捆绑”在一起就可以了.在Java中,我们使用“”来实现这样一个效果~

特性

互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行
到同一个对象 synchronized 就会阻塞等待.
• 进入 synchronized 修饰的代码块, 相当于 加锁
• 退出 synchronized 修饰的代码块, 相当于 解锁
在这里插入图片描述

  • synchronized用的锁是存在Java对象头里的。
    在这里插入图片描述

  • 可以粗略理解成, 每个对象在内存中存储的时候, 都存有一块内存表示当前的 “锁定” 状态(类似于厕所的 “有人/无人”).

    • 如果当前是 “无人” 状态, 那么就可以使用, 使用时需要设为 “有人” 状态.
    • 如果当前是 “有人” 状态, 那么其他人无法使用, 只能排队
      在这里插入图片描述
理解 “阻塞等待”
针对每一把锁, 操作系统内部都维护了一个等待队列. 当这个锁被某个线程占有的时候, 其他线程尝试
进行加锁, 就加不上了, 就会阻塞等待, 一直等到之前的线程解锁之后, 由操作系统唤醒一个新的线程,
再来获取到这个锁.
注意:
• 上一个线程解锁之后, 下一个线程并不是立即就能获取到锁. 而是要靠操作系统来 "唤醒". 这也就
是操作系统线程调度的一部分工作.
• 假设有 A B C 三个线程, 线程 A 先获取到锁, 然后 B 尝试获取锁, 然后 C 再尝试获取锁, 此时 B 和 C
都在阻塞队列中排队等待. 但是当 A 释放锁之后, 虽然 B 比 C 先来的, 但是 B 不一定就能获取到锁,
而是和 C 重新竞争, 并不遵守先来后到的规则.
可重入
  • synchronized 同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题;
理解“把自己锁死”
  • 一个线程没有释放锁, 然后又尝试再次加锁.
    // 第一次加锁, 加锁成功
    lock();
    // 第二次加锁, 锁已经被占用, 阻塞等待.
    lock();
    按照之前对于锁的设定, 第二次加锁的时候, 就会阻塞等待. 直到第一次的锁被释放, 才能获取到第二个锁. 但是释放第一个锁也是由该线程来完成, 结果这个线程已经躺平了, 啥都不想干了, 也就无法进行解锁操作. 这时候就会产生死锁.

  • Java 中的 synchronized 是 可重入锁, 因此没有上面的问题

对于这样一个例子

for (int i = 0; i < 50000; i++) {
 synchronized (locker) {
 	synchronized (locker) {
 			count++;
 		}
	 }
}
  • synchronized的左括号和右括号可以理解为加锁和解锁

在这里插入图片描述

synchronized使用

  • synchronized 本质上要修改指定对象的 “对象头”. 从使用角度来看, synchronized 也势必要搭配一个具体的对象来使用
  1. 修饰代码块: 明确指定锁哪个对象.
    1️⃣锁住任意对象
public class SynchronizedDemo {
 private Object locker = new Object();

 public void method() {
 synchronized (locker) {

 }
 }
}

2️⃣锁住当前对象

public class SynchronizedDemo {
 public void method() {
 synchronized (this) {
 }
 }
}

  1. 直接修饰普通方法: 锁的 SynchronizedDemo 对象
public class SynchronizedDemo {
 public synchronized void methond() {
 }
}
  1. 修饰静态方法: 锁的 SynchronizedDemo 类的对象
public class SynchronizedDemo {
 public synchronized static void method() {
 }
}

死锁

  • 日常开发中,一旦涉及到多线程的问题,就容易产生死锁。
死锁的四个条件
  1. 锁是“互斥”的
  • synchronized 是互斥的,但不代表所有锁都是互斥的,synchronized自动满足这个条件
  1. 锁不可被抢占
  • 线程1 获取锁A
  • 线程2 也尝试获取锁A
  • 此时线程2 阻塞等待,一直等到线程1释放锁
    对于synchronized来说,这个条件也是自动具备的
  1. 保持后再请求
  • 一个线程,获取到锁A,在持有锁A的情况下,再去获取锁B。
  1. 循环等待
  • 即使有多把锁,请求保持的方式进行使用了,得到多个线程的等待锁的顺序,出现循环了,才会出现死锁。
  • 如果不是循环等待,就不会死锁

死锁样例代码:
在这里插入图片描述
解决方案:

package Thread;

public class Demo19 {
    public static void main(String[] args) {
        Object lockerA = new Object();
        Object lockerB = new Object();

        Thread t1=new Thread(()->{
            synchronized (lockerA) {
                try {
                    // 加sleep是为了确保对方线程已经把lockerB锁上
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (lockerB) {
                    System.out.println("t1");
                }
            }
        });

        Thread t2=new Thread(()->{
            synchronized (lockerA) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }

                synchronized (lockerB) {
                    System.out.println("t2");
                }
            }

        });
        t1.start();
        t2.start();
    }
}
Java标准库中的线程安全类
  • Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措
    施.
    • ArrayList
    • LinkedList
    • HashMap
    • TreeMap
    • HashSet
    • TreeSet
    • StringBuilder
    但是还有一些是线程安全的. 使用了一些锁机制来控制.
    • Vector (不推荐使用)
    • HashTable (不推荐使用)
    • ConcurrentHashMap
    • StringBuffer

内容可见性引起的线程安全问题

先看一段代码:

package Thread;

import java.util.Scanner;

public class Demo21 {
    private static boolean flag=true;

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag){

            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(()->{
            System.out.println("请输入任意内容,终止t1线程");
            Scanner scanner = new Scanner(System.in);
            scanner.next();
            //通过修改flag让t1线程结束
            flag=false;
            System.out.println("flag = " + flag);
        });
        t1.start();
        t2.start();
    }
}

运行结果
在这里插入图片描述

  • 此处就是编译器优化在多线程的环境下产生的“误判”,导致优化有的结果不一样了。
    在这里插入图片描述
    在这里插入图片描述
volatile关键字
package Thread;

import java.util.Scanner;

public class Demo21 {
    private static volatile boolean flag=true;//加上volatile关键字

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            while(flag){
//                try {
//                    Thread.sleep(1);
//                } catch (InterruptedException e) {
//                    throw new RuntimeException(e);
//                }
            }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(()->{
            System.out.println("请输入任意内容,终止t1线程");
            Scanner scanner = new Scanner(System.in);
            scanner.next();
            //通过修改flag让t1线程结束
            flag=false;
            System.out.println("flag = " + flag);
        });
        t1.start();
        t2.start();
    }
}

volatile和synchronized对比

你想了解 Java 中 volatilesynchronized 的区别与对比,我会从核心功能、使用场景、底层实现、优缺点这几个维度帮你梳理清楚,同时明确它们的适用边界。

首先要先明确一个核心前提:volatilesynchronized 都是用来解决多线程并发安全问题的,但它们的能力范围、性能开销有很大差异,synchronized 是更“重量级”的解决方案,volatile 是轻量级的补充。


一、核心功能对比

先通过一张表格快速掌握核心差异,再逐一展开解释:

对比维度 volatile synchronized
核心作用 保证可见性禁止指令重排序不保证原子性 保证可见性原子性间接保证有序性(通过加锁)
适用对象 只能修饰实例变量、静态变量(不能修饰方法、代码块) 可修饰实例方法、静态方法、同步代码块(锁对象可为任意引用、Class 对象)
锁的特性 无锁机制,轻量级,不会造成线程阻塞 有锁机制(重量级,JDK 1.6 后优化为偏向锁/轻量级锁/重量级锁),可能造成线程阻塞(等待锁释放)
原子性支持 不支持(仅能保证单个变量的读/写操作可见,复合操作如 i++ 仍不安全) 支持(对同步代码块/方法内的所有操作进行原子性包裹,多步操作不会被打断)
性能开销 极低,几乎无额外开销,接近普通变量访问 开销较高(锁的获取/释放、线程上下文切换),JDK 1.6 后优化大幅降低差距
适用场景 单变量的多线程读/写、状态标记位(如停止线程)、双重检查锁定(DCL) 多变量复合操作、临界区代码(多步操作需保证原子性)、资源互斥访问

二、关键特性详细解释
1. 可见性

多线程环境下,一个线程修改了共享变量的值,其他线程能立即看到这个修改后的值(避免线程读取到自己工作内存中的缓存副本,强制从主内存读取/写入)。

  • volatile直接保证可见性。被 volatile 修饰的变量,修改后会立即刷新到主内存,读取时会直接从主内存加载,跳过线程的工作内存缓存。
  • synchronized间接保证可见性。线程释放锁时,会将工作内存中的修改刷新到主内存;线程获取锁时,会清空工作内存缓存,从主内存重新加载变量值。
2. 原子性

原子性指一个操作或多个操作,要么全部执行完成且中间不会被打断,要么全部不执行
这是两者最核心的区别,也是新手最容易踩坑的点。

案例1:volatile 不保证原子性
public class VolatileNoAtomicDemo {
    // 用 volatile 修饰共享变量
    private static volatile int count = 0;
    // 循环次数
    private static final int LOOP = 1000;

    public static void main(String[] args) throws InterruptedException {
        // 线程1:执行 1000 次 count++
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < LOOP; i++) {
                count++;
            }
        });

        // 线程2:执行 1000 次 count++
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < LOOP; i++) {
                count++;
            }
        });

        t1.start();
        t2.start();
        // 等待两个线程执行完成
        t1.join();
        t2.join();

        // 预期结果:2000,实际结果大概率小于 2000
        System.out.println("最终 count 值:" + count);
    }
}

原因count++ 并不是一个原子操作,它拆解为 3 步:

  1. 从主内存读取 count 的值到工作内存;
  2. 在工作内存中对 count 执行 +1 操作;
  3. 将修改后的 count 值刷新回主内存。

volatile 只能保证每一步的可见性,但无法保证这 3 步操作不被其他线程打断。比如线程1执行完步骤2,还没来得及刷新回主内存,线程2就读取了旧值,最终导致两次 ++ 只生效了一次。

案例2:synchronized 保证原子性

将上面的 count++synchronized 包裹,就能得到正确结果:

public class SynchronizedAtomicDemo {
    private static int count = 0; // 无需 volatile,synchronized 已保证可见性
    private static final int LOOP = 1000;
    // 锁对象(静态方法可用 Class 对象,实例方法可用 this)
    private static final Object LOCK = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < LOOP; i++) {
                synchronized (LOCK) { // 同步代码块,保证 count++ 原子性
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < LOOP; i++) {
                synchronized (LOCK) {
                    count++;
                }
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        // 预期结果:2000,实际结果必然是 2000
        System.out.println("最终 count 值:" + count);
    }
}

原因synchronized 保证了同一时间只有一个线程能进入同步代码块,count++ 的 3 步操作会被完整执行,不会被其他线程打断,从而保证了原子性。

3. 有序性

有序性指程序执行的顺序按照代码的书写顺序执行,多线程环境下,JVM 为了优化性能可能会进行指令重排序(不影响单线程执行结果,但会破坏多线程执行逻辑)。

  • volatile直接保证有序性。通过内存屏障(Memory Barrier)禁止指令重排序,被 volatile 修饰的变量,其前后的指令不会被重排序,且能保证变量的修改对其他线程可见。
    典型场景:双重检查锁定(DCL)实现单例模式(解决指令重排序导致的半初始化对象问题)。
  • synchronized间接保证有序性。同一时间只有一个线程能执行同步代码块,相当于让线程串行执行同步代码,自然不会存在指令重排序的问题。
有序性典型案例:DCL 单例(必须用 volatile
public class SingletonDCL {
    // 必须用 volatile 修饰,禁止指令重排序
    private static volatile SingletonDCL instance;

    // 私有构造方法,防止外部实例化
    private SingletonDCL() {}

    // 双重检查锁定
    public static SingletonDCL getInstance() {
        // 第一层检查:避免每次获取实例都加锁(提高性能)
        if (instance == null) {
            // 同步锁:保证多线程下只有一个线程能进入
            synchronized (SingletonDCL.class) {
                // 第二层检查:避免多个线程等待锁后重复实例化
                if (instance == null) {
                    // 实例化对象(3步操作,可能被指令重排序)
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
}

如果没有 volatileinstance = new SingletonDCL() 可能被重排序为:

  1. 分配内存空间;
  2. instance 指向该内存空间(此时 instance 不为 null,但对象还未初始化);
  3. 初始化对象。

这会导致其他线程在第一层检查时,发现 instance 不为 null,直接返回一个未初始化完成的半对象,造成程序异常。volatile 可以禁止这种重排序,保证对象完全初始化后才被其他线程可见。


三、使用场景选择
  1. 优先使用 volatile 的场景(轻量、无复合操作):

    • 多线程共享的状态标记位(如停止线程、开关控制):
      private volatile boolean isStop = false;
      
      public void stopThread() {
          isStop = true; // 线程1修改标记
      }
      
      public void run() {
          while (!isStop) { // 线程2立即感知标记变化
              // 执行任务
          }
      }
      
    • 多线程下单个变量的读/写操作(无 ++--+= 等复合操作)。
    • 双重检查锁定(DCL)单例模式中的实例变量。
  2. 必须使用 synchronized 的场景(需要原子性、复合操作):

    • 多线程下的复合操作(如 i++count += 5、多变量修改)。
    • 多线程共享临界资源(如共享集合、数据库连接、文件操作),需要互斥访问。
    • 多步操作需要保证原子性(如先查询、再修改、最后保存)。

总结
  1. 核心差异:volatile 保证可见性、有序性,不保证原子性,无锁轻量;synchronized 保证可见性、原子性、有序性,有锁重量级。
  2. 性能:volatile 开销远低于 synchronized(JDK 1.6 后 synchronized 优化差距缩小,但仍高于 volatile)。
  3. 选择原则:能使用 volatile 解决的问题优先用 volatile(追求性能),需要原子性时必须用 synchronized(保证安全),二者不是互斥关系,而是互补关系。

wait 和 notify

在这里插入图片描述
wait 做的事情:
• 使当前执行代码的线程进行等待. (把线程放到等待队列中)
• 释放当前的锁
• 满足一定条件时被唤醒, 重新尝试获取这个锁.
wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.
wait 结束等待的条件:
• 其他线程调用该对象的 notify 方法.
• wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
• 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常

package Thread;

public class Demo22_wait {
    public static void main(String[] args) throws InterruptedException {
        Object object =new Object();
        synchronized (object) {
            System.out.println("wait之前");
            object.wait();
            System.out.println("wait之后");
        }
    }
}

在这里插入图片描述

  • 这样在执行到object.wait()之后就一直等待下去,那么程序肯定不能一直这么等待下去了。这个时候就需要使用到了另外一个方法唤醒的方法notify()。

notify 方法是唤醒等待的线程
• 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
• 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 “先来后到”)
• 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

package Thread;

import java.util.Scanner;

public class Demo23 {
    public static void main(String[] args) {
        Object object=new Object();
        Thread t1=new Thread(()->{
            synchronized (object){
               System.out.println("t1 wait之前");
                try {
                    object.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
               System.out.println("t1 wait之后");
            }
        });

        Thread t2=new Thread(()->{
           synchronized (object){
               System.out.println("请输入任意内容启动t1线程");
               Scanner scanner = new Scanner(System.in);
               scanner.next();
               synchronized (object){
                   object.notify();
               }
           }
        });
        t1.start();
        t2.start();
    }
}

在这里插入图片描述
你想了解这段Java多线程代码的执行流程,我会先帮你总结代码的整体功能,再一步步拆解它的执行过程,最后还会指出其中的小细节。

首先,这段代码的核心功能是:通过Objectwait()notify()方法实现线程间通信,让t1线程先暂停等待,直到t2线程接收到用户输入后,唤醒t1线程继续执行

步骤1:初始化准备
  1. 程序运行,进入main方法,首先创建一个Object对象(作为线程间共享的锁对象和通信载体)。
  2. 创建线程t1t2,此时两个线程都处于“新建状态”,还未开始执行。
  3. 调用t1.start()t2.start(),两个线程进入“就绪状态”,等待CPU调度执行(CPU调度顺序不确定,大概率t1会先被调度,也可能t2先被调度,不影响最终核心逻辑)。
步骤2:假设t1先被CPU调度执行
  1. t1线程进入lambda表达式,执行synchronized (object),成功获取object对象锁,进入同步代码块。
  2. 打印输出:t1 wait之前
  3. 执行object.wait()
    • t1线程主动释放object(这是wait()的关键特性,否则t2无法获取锁)。
    • t1线程进入object的等待队列,暂停执行(不再往下走),等待被唤醒。
步骤3:t2线程被CPU调度执行(此时t1已释放锁,t2可以获取锁)
  1. t2线程进入lambda表达式,执行synchronized (object),成功获取object对象锁,进入同步代码块。
  2. 打印输出:请输入任意内容启动t1线程
  3. 创建Scanner对象,执行scanner.next():程序暂停,等待用户在控制台输入任意内容并按下回车(此时程序阻塞在用户输入环节,t2线程持有object锁,处于暂停状态)。
  4. 用户输入任意内容(比如“123”)并回车后,scanner.next()获取到输入内容,继续往下执行。
  5. 执行内层synchronized (object):这是一个多余的嵌套同步代码块t2已经持有object锁,再次获取同一把锁是允许的,这叫“可重入锁”),进入内层同步代码块。
  6. 执行object.notify():唤醒object等待队列中的一个线程(也就是t1)。
    • 注意:notify()不会立即释放锁,锁仍然由t2持有。
    • t1被唤醒后,不会立即执行,而是进入“就绪状态”,等待重新获取object锁。
  7. 内层同步代码块执行完毕,释放锁(可重入锁的释放是逐层的,这里释放的是内层嵌套的锁,实际还是同一把object锁,无实质变化)。
  8. t2的外层同步代码块执行完毕,彻底释放object
步骤4:t1线程重新竞争并获取object锁,继续执行
  1. t2释放锁后,t1(已被唤醒)成功竞争到object锁,重新进入之前暂停的同步代码块。
  2. object.wait()语句之后继续往下执行,打印输出:t1 wait之后
  3. t1的同步代码块执行完毕,释放object锁。
  4. t1线程执行完毕,进入“终止状态”。
步骤5:程序收尾

t2线程也已执行完毕,进入“终止状态”,整个Java程序运行结束。

三、补充说明
  1. 关于线程调度顺序:如果CPU先调度t2t2会先获取锁,然后等待用户输入,输入完成后调用notify()(此时t1还没进入wait()notify()的唤醒信号会“丢失”),之后t2释放锁,t1获取锁执行wait(),此时t1会一直处于等待状态,永远无法被唤醒(这是wait()/notify()的一个坑,这段代码的调度顺序可能导致这个问题)。
  2. 嵌套synchronized (object)无意义:t2已经持有object锁,内层再次加锁不会有任何额外效果,完全可以删除内层的synchronized代码块,直接调用object.notify()即可。
  3. 输出结果(正常情况,t1先执行):
    t1 wait之前
    请输入任意内容启动t1线程
    (这里输入任意内容,比如123,回车)
    t1 wait之后
    
总结
  1. 这段代码的核心是利用wait()(释放锁、暂停等待)和notify()(唤醒等待线程)实现线程间通信,t1等待t2的用户输入信号后再继续执行。
  2. wait()/notify()必须在synchronized同步代码块/方法内调用,且依赖同一个锁对象。
  3. wait()会释放锁,notify()不会立即释放锁,需等待当前同步代码块执行完毕才会释放锁。
补充:wait 和 sleep 的对比
  • 其实理论上 wait 和 sleep 完全是没有可比性的,因为一个是用于线程之间的通信的,一个是让线程阻塞一段时间(然后继续往下进行),唯一的相同点就是都可以让线程放弃执行一段时间.
    当然为了面试的目的,我们还是总结下:
    1. wait 需要搭配 synchronized 使用. sleep 不需要.
    2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.

多线程案例

单例模式

  • 啥是设计模式?
    • 设计模式好比象棋中的 “棋谱”. 红方当头炮, 黑方马来跳. 针对红方的一些走法, 黑方应招的时候有一些固定的套路. 按照套路来走局势就不会吃亏.
      软件开发中也有很多常见的 “问题场景”. 针对这些问题场景, 大佬们总结出了一些固定的套路. 按照这个套路来实现代码, 也不会吃亏.
  • 单例模式能保证某个类在程序中只存在唯一 份实例, 而不会创建出多个实例

饿汉模式

  • 类加载的同时, 创建实例.
package Thread;

//这个类就要设计成单例模式,只能有一个实例
class Singleton{
    //借助static关键字,确保只有一个实例
     static Singleton instance = new Singleton();

    public static Singleton getInstance(){
        return instance;
    }
    //构造方法私有化,防止外部实例化
    private Singleton(){
    }
}

public class Demo25_Singleton {
    public static void main(String[] args) {
//      Singleton singleton = new Singleton();//编译错误
        Singleton s1 = Singleton.getInstance();
        Singleton s2 = Singleton.getInstance();
        System.out.println(s1==s2);
    }
}

懒汉模式

  • 类加载的时候不创建实例. 第一次使用的时候才创建实例.
package Thread;

class SingletonLazy{
    private static volatile SingletonLazy instance = new SingletonLazy();
    private static Object locker = new Object();
    public static SingletonLazy getInstance(){
        if(instance!=null){
            synchronized (locker){
                if(instance==null){
                    instance = new SingletonLazy();
                }
            }
        }
        return instance;
    }
}
public class Demo26 {
    public static void main(String[] args) {

    }
}

  • 理解双重 if 判定 / volatile:
    • 加锁 / 解锁是一件开销比较高的事情. 而懒汉模式的线程不安全只是发生在首次创建实例的时候. 因此后续使用的时候, 不必再进行加锁了.
    • 外层的 if 就是判定下看当前是否已经把 instance 实例创建出来了.
    • 同时为了避免 “内存可见性” 导致读取的 instance 出现偏差, 于是补充上 volatile .
    • 当多线程首次调用 getInstance, 大家可能都发现 instance 为 null, 于是又继续往下执行来竞争锁, 其中竞争成功的线程, 再完成创建实例的操作.
    • 当这个实例创建完了之后, 其他竞争到锁的线程就被里层 if 挡住了. 也就不会继续创建其他实例.

阻塞队列

package Thread;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class Demo27_阻塞队列 {
    public static void main(String[] args) throws InterruptedException {
        BlockingQueue<String> queue = new ArrayBlockingQueue<String>(4);
        queue.put("111");
        queue.put("222");
        queue.put("333");
        queue.put("444");
//        queue.put("555");//发生阻塞

        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
        System.out.println(queue.take());
    }
}

生产者消费者模型

案例

package Thread;

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class Demo28_生产者消费者 {
    public static void main(String[] args) {
        //编写一个生产者消费者模型
        BlockingQueue<String> queue = new ArrayBlockingQueue<String>(1000);

        //生产者和消费者都是线程
        Thread prouder = new Thread(()->{
            int count =0;
            try {
                while(true){
                    count++;
                    queue.put(count+" ");
                    System.out.println("生产元素: "+count);

                    //生产一个元素,消费者就可以消费一个元素
//                    Thread.sleep(1000);
                }
           } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        Thread consumer = new Thread(()->{
            try {
                while(true){
                    System.out.println("消费元素: "+queue.take());

                    //消费一个元素,生产者就可以生产一个元素
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });

        prouder.start();
        consumer.start();
    }
}

步骤 1:线程启动与首次执行

  1. prouder.start()consumer.start() 调用后,两个线程进入“就绪状态”,等待 CPU 调度。
  2. 假设调度器先给 prouder 分配 CPU(也可能先给 consumer):
    • 生产者线程进入 while(true) 无限循环,count 从 0 变为 1。
    • 调用 queue.put("1 "):此时队列为空,容量还剩 999,put() 方法直接存入数据,不会阻塞。
    • 执行 System.out.println("生产元素: 1"),控制台打印生产信息。
    • 循环再次执行,count 变为 2,继续 put("2 "),以此类推(生产速度极快,因为没有 sleep)。
  3. 同时,调度器会切换 CPU 执行权给 consumer(线程切换是高频的):
    • 消费者线程进入 while(true) 无限循环,调用 queue.take()
    • 此时队列中已经有生产者存入的数据(至少 1 个),take() 方法直接取出队列头部的数据(先存先取,FIFO 先进先出)。
    • 执行 System.out.println("消费元素: 1 "),控制台打印消费信息。
    • 调用 Thread.sleep(1000),消费者线程进入“阻塞状态”,暂停 1 秒(这 1 秒内不会竞争 CPU,生产者可以疯狂生产)。
    • 1 秒后,sleep() 结束,消费者线程回到“就绪状态”,等待 CPU 调度,再次执行 take() 取出下一个数据。

步骤 2:无限循环中的持续执行(核心约束)

  1. 生产者的执行逻辑:没有 sleep,生产速度极快,会不断调用 queue.put() 往队列中存数据,直到队列被存满(达到 1000 个元素)。
    • 当队列容量为 1000 时,再次调用 queue.put()put() 方法会阻塞生产者线程(生产者进入阻塞状态,放弃 CPU 执行权),直到队列中有元素被消费者取走(队列有空闲位置),才会被唤醒,继续存入数据。
  2. 消费者的执行逻辑:每消费一个元素后,会 sleep(1000) 1 秒,消费速度很慢(每秒 1 个)。
    • 当队列为空时(极端情况:生产者还没生产,或生产速度赶不上消费速度),调用 queue.take()take() 方法会阻塞消费者线程,直到队列中有生产者存入新数据,才会被唤醒,取出数据并消费。
  3. 控制台打印的特点
    • 生产者的打印信息会“批量出现”(因为生产速度快,CPU 一次调度能生产很多个)。
    • 消费者的打印信息会“每秒出现一个”(因为 sleep(1000))。
    • 永远不会出现“消费不存在的元素”,因为 take() 会阻塞等待生产;也不会出现“队列溢出”,因为 put() 会在队列满时阻塞等待消费(这就是阻塞队列的核心价值,无需手动处理同步)。

步骤 3:特殊情况补充(线程中断)

两个线程的 try 块中都捕获了 InterruptedException,这是因为 put()take() 方法都是可中断的

  • 如果外部线程调用 prouder.interrupt()consumer.interrupt(),阻塞在 put()take() 方法上的线程会被唤醒,抛出 InterruptedException,最终被包装成 RuntimeException 抛出,线程终止。

补充:代码中容易被忽略的点

  1. ArrayBlockingQueue有界阻塞队列,构造时指定了容量 1000,这是生产者的“上限”。
  2. 生产者没有 sleep,消费者有 1 秒 sleep,因此队列最终会被存满,生产者会被阻塞,之后进入“消费者取一个,生产者存一个”的平衡状态。
  3. 控制台的打印顺序不代表线程的执行顺序System.out.println() 是同步方法,但线程切换可能导致生产信息和消费信息交叉打印(比如先打印“生产 100”,再打印“消费 1”)。

总结

  1. 整体流程:main 方法初始化队列 → 创建并启动生产者/消费者线程 → 两个线程无限循环执行生产/消费。
  2. 线程特性:生产者和消费者并行执行,CPU 调度无固定顺序,生产速度远快于消费速度。
  3. 阻塞队列约束:put() 队列满时阻塞生产者,take() 队列空时阻塞消费者,保证数据安全且无溢出。

手搓阻塞队列

package Thread;

class MyBlockingQueue{
    private String[] data= null;
    private int head=0;
    private int tail=0;
    private int size=0;

    private final Object locker=new Object();

    public MyBlockingQueue(int capacity){
        data = new String[capacity];
    }
    public void put(String elem) throws InterruptedException {
        synchronized (locker){
            if(size==data.length){
                //队列满了
                locker.wait();
            }
            data[tail]=elem;
            tail++;
            if(tail>=data.length){
                tail=0;
            }
            size++;
            locker.notify();
        }
    }

    public String take() throws InterruptedException {
        synchronized (locker){
            if(size==0){
                locker.wait();
            }
            String ret=data[head];
            head++;
            if(head>= data.length){

                head=0;
            }
            size--;
            locker.notify();
            return ret;
        }
    }
}

public class Demo29_手搓阻塞队列 {
}

你想了解这段手动实现的阻塞队列代码的执行流程,我会从整体功能核心变量关键方法执行步骤三个层面,由浅入深地为你拆解它的执行逻辑。

首先先明确:这段代码实现了一个有界阻塞队列(基于数组的循环队列),核心特性是:

  1. 队列满时,调用put()方法的线程会阻塞等待,直到队列有空闲位置。
  2. 队列空时,调用take()方法的线程会阻塞等待,直到队列中有元素。
  3. 依赖synchronizedObjectwait()/notify()实现线程安全和阻塞唤醒机制。

第一步:先理解核心成员变量

在看方法执行前,先搞懂类中每个变量的作用,这是理解执行流程的基础:

变量名 类型 作用
data String[] 存储队列元素的底层数组,队列的容量就是数组的长度
head int 队列的队头指针,指向当前可以取出元素的位置
tail int 队列的队尾指针,指向当前可以存入元素的位置
size int 记录队列中当前实际的元素个数(避免通过headtail计算,简化逻辑)
locker Object 全局锁对象,用于保证线程安全,同时作为wait()/notify()的调用对象

补充:这是一个循环队列,意味着headtail到达数组末尾后,会重新回到数组起始位置(索引0)。


第二步:构造方法执行流程
public MyBlockingQueue(int capacity){
    data = new String[capacity];
}

这是一个简单的带参构造方法,执行逻辑只有1步:

  1. 接收调用者传入的capacity(队列容量,即最多能存多少个元素)。
  2. 创建一个长度为capacityString数组,赋值给成员变量data,作为队列的底层存储容器。
  3. 此时headtail默认值为0,size默认值为0,队列处于空队列状态。

示例:调用new MyBlockingQueue(5),会创建一个容量为5的空阻塞队列,data数组长度为5,所有元素初始为null


第三步:put()方法执行流程(存入元素)

put()方法的作用是向队列中存入一个元素,如果队列已满,则当前线程会阻塞等待,直到队列有空闲位置,执行流程如下(全程在同步代码块中,保证线程安全):

public void put(String elem) throws InterruptedException {
    // 步骤1:获取locker对象的锁,进入同步代码块(线程安全)
    synchronized (locker){
        // 步骤2:判断队列是否已满(当前元素个数 == 数组长度)
        if(size==data.length){
            // 队列满了:当前线程释放locker锁,进入阻塞等待状态
            locker.wait();
        }
        // 步骤3:执行到这里,说明队列有空闲位置,将元素存入tail指向的数组位置
        data[tail]=elem;
        // 步骤4:tail指针向后移动一位(准备下一次存入元素)
        tail++;
        // 步骤5:判断tail是否超出数组长度,若是则重置为0(实现循环队列)
        if(tail>=data.length){
            tail=0;
        }
        // 步骤6:队列实际元素个数+1
        size++;
        // 步骤7:唤醒一个正在locker上阻塞等待的线程(可能是等待take的空队列线程)
        locker.notify();
    }
    // 步骤8:同步代码块结束,当前线程释放locker锁
}
关键细节拆解:
  1. synchronized (locker):所有调用put()take()的线程,都要竞争locker这把锁,同一时间只有一个线程能进入同步代码块,避免并发修改dataheadtailsize带来的线程安全问题。
  2. locker.wait()
    • 当队列满时,当前线程会释放locker(非常重要,否则其他线程无法操作队列,会造成死锁),然后进入阻塞状态,暂停执行。
    • 该线程会一直等待,直到被其他线程调用locker.notify()locker.notifyAll()唤醒。
    • 唤醒后,线程不会立即执行后续代码,而是会重新竞争locker锁,获取到锁后,会重新检查size==data.length这个条件(这里代码用了if,其实更严谨的是用while,避免虚假唤醒),确认队列有空闲后才会继续存入元素。
  3. locker.notify():存入元素后,队列从“空”或“未满”变为“非空”或“满”,此时唤醒一个阻塞在locker上的线程(大概率是调用take()时因队列为空而阻塞的线程),让它有机会取出元素。
  4. 循环队列实现:当tail移动到数组末尾(tail == data.length),将其重置为0,下次存入元素时就会从数组起始位置开始,避免数组前面的空闲位置被浪费。

示例:向容量为5的队列中存入3个元素(“A”、“B”、“C”)

  • 存入"A":data[0] = "A"tail=1size=1
  • 存入"B":data[1] = "B"tail=2size=2
  • 存入"C":data[2] = "C"tail=3size=3

第四步:take()方法执行流程(取出元素)

take()方法的作用是从队列中取出并返回一个元素,如果队列为空,则当前线程会阻塞等待,直到队列中有元素,执行流程和put()对称(全程在同步代码块中):

public String take() throws InterruptedException {
    // 步骤1:获取locker对象的锁,进入同步代码块(线程安全)
    synchronized (locker){
        // 步骤2:判断队列是否为空(当前元素个数 == 0)
        if(size==0){
            // 队列空了:当前线程释放locker锁,进入阻塞等待状态
            locker.wait();
        }
        // 步骤3:执行到这里,说明队列中有元素,取出head指向的数组元素
        String ret=data[head];
        // 步骤4:head指针向后移动一位(准备下一次取出元素)
        head++;
        // 步骤5:判断head是否超出数组长度,若是则重置为0(实现循环队列)
        if(head>= data.length){
            head=0;
        }
        // 步骤6:队列实际元素个数-1
        size--;
        // 步骤7:唤醒一个正在locker上阻塞等待的线程(可能是等待put的满队列线程)
        locker.notify();
        // 步骤8:返回取出的元素
        return ret;
    }
    // 步骤9:同步代码块结束,当前线程释放locker锁
}
关键细节拆解:
  1. locker.wait():和put()方法类似,队列为空时,当前线程释放锁并阻塞,直到被其他线程唤醒,唤醒后重新竞争锁并检查队列是否非空。
  2. locker.notify():取出元素后,队列从“满”或“非空”变为“未满”或“空”,此时唤醒一个阻塞在locker上的线程(大概率是调用put()时因队列满而阻塞的线程),让它有机会存入元素。
  3. 返回值:取出的是head指向的元素,这符合队列“先进先出(FIFO)”的特性(先存入的元素会先被取出)。

示例:承接上面存入3个元素的队列,调用3次take()

  • 第一次take():取出data[0](“A”),head=1size=2,返回"A"
  • 第二次take():取出data[1](“B”),head=2size=1,返回"B"
  • 第三次take():取出data[2](“C”),head=3size=0,返回"C"

第五步:线程间的协作示例

为了让你更直观理解,我们模拟一个“生产者线程存入元素,消费者线程取出元素”的完整场景:

  1. 创建一个容量为2的MyBlockingQueue
  2. 生产者线程1调用put("X"):队列未满,存入成功,tail=1size=1,唤醒可能存在的消费者线程。
  3. 生产者线程1继续调用put("Y"):队列未满,存入成功,tail=2→重置为0,size=2(队列满),唤醒可能存在的消费者线程。
  4. 生产者线程1再次调用put("Z"):队列已满,执行locker.wait(),释放锁并阻塞。
  5. 消费者线程2调用take():队列非空,取出"X",head=1size=1(队列未满),调用notify()唤醒生产者线程1。
  6. 生产者线程1被唤醒,重新竞争到锁,检查队列未满,存入"Z",tail=1size=2,唤醒消费者线程。
  7. 消费者线程2继续调用take():取出"Y",head=2→重置为0,size=1,唤醒生产者线程。
  8. 以此类推,实现生产者和消费者的协调工作,不会出现“队列满了还存”或“队列空了还取”的问题。
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐