wait-ify

基本使用

需要获取对象锁后才可以调用 锁对象.wait(),notify 随机唤醒一个线程,notifyAll 唤醒所有线程去竞争 CPU

Object 类 API:

public final void notify():唤醒正在等待对象监视器的单个线程。
public final void notifyAll():唤醒正在等待对象监视器的所有线程。
public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。
public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒

说明:wait 是挂起线程,需要唤醒的都是挂起操作,阻塞线程可以自己去争抢锁,挂起的线程需要唤醒后去争抢锁

想象一下:你和朋友约好去吃饭,但你说:“我还没到,你先等等我。”等你到了,你就说:“我到了,可以吃饭了!”——这就是 线程间的通信,而 waitnotify 就是这种“等我”、“我好了”的对话工具。

  • wait():我还没准备好,我先“挂起”(暂停),你先干别的。
  • notify():我搞定了,叫醒一个在等的人。
  • notifyAll():我搞定了,所有人别等了,都醒醒抢活干!

对比 sleep():

  • 原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
  • 锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
  • 使用区域不同:wait() 方法必须放在**同步控制方法和同步代码块(先获取锁)**中使用,sleep() 方法则可以放在任何地方使用

sleep()wait()虽然都能让线程“暂停”,但目的、用法、行为完全不同。

第一点:原理不同

  • Thread.sleep():是线程自己控制自己,“我想歇2秒”。
  • Object.wait():是线程之间互相配合,“我没准备好,等你通知我”。
方法 场景
sleep() 你做饭做到一半,说:“我先躺2分钟再炒菜” → 你占着灶台,别人不能用
wait() 你发现没食材,就对室友说:“我等你买菜回来叫我” → 你让出厨房,室友可以进去放东西、做饭

总结一下:sleep 是自我管理,wait 是协作沟通。sleep() 是线程的“个人行为”,wait() 是多个线程之间的“团队协作”。

// sleep 属于 Thread 类 → Thread.sleep()
Thread.sleep(1000);

// wait 属于 Object 类 → 必须某个对象调用 obj.wait()
synchronized (lock) {
    lock.wait();
}

第二点:对锁的处理机制不同

方法 是否释放锁? 是否释放 CPU?
sleep() 不释放 释放
wait() 释放 释放

也就是说:

  • sleep():占着茅坑不拉屎(有锁不干活)
  • wait():主动让位,让别人进来改数据
Object lock = new Object();

// 线程A:使用 sleep —— 不释放锁
new Thread(() -> {
    synchronized (lock) {
        System.out.println("A 拿到锁,开始 sleep...");
        try {
            Thread.sleep(5000); // 睡5秒,但锁不放!
        } catch (InterruptedException e) {}
        System.out.println("A sleep 结束");
    }
}, "A").start();

// 线程B:想进 lock,但必须等 A 出来
new Thread(() -> {
    synchronized (lock) {
        System.out.println("B 终于拿到锁了!");
    }
}, "B").start();

代码输出如下:

A 拿到锁,开始 sleep...
(5秒后)
A sleep 结束
B 终于拿到锁了!

B 等了整整5秒!因为 A 睡着也不放锁!换成 wait()

Object lock = new Object();

// 线程C:使用 wait —— 释放锁
new Thread(() -> {
    synchronized (lock) {
        System.out.println("C 拿到锁,发现条件不满足,开始 wait...");
        try {
            lock.wait(); // 释放锁!
        } catch (InterruptedException e) {}
        System.out.println("C 被唤醒,继续执行");
    }
}, "C").start();

// 线程D:可以立刻拿到锁(因为C释放了)
new Thread(() -> {
    synchronized (lock) {
        System.out.println("D 拿到锁了!可以干活了!");
        // 干完活,叫醒 C
        lock.notify();
    }
}, "D").start();

代码输出如下:

C 拿到锁,发现条件不满足,开始 wait...
D 拿到锁了!可以干活了!
C 被唤醒,继续执行

看到了吗?C 一 wait(),D 立刻就能进!这就是释放锁的好处。如果不释放锁,别人进不来,也就没法调用 notify() 来唤醒你,就死锁了。

第三点:使用区域不同

  • wait():必须先“进门”(拿到锁),才能说“我等等”
  • sleep():在哪都能睡,站着也能打盹
  • wait() 像开会时说:“这事儿没结论,咱们下次再议” → 你得先在会议室里才能说这话
  • sleep() 像打哈欠:“我困了,眯一会儿” → 在工位、走廊都能眯
Object lock = new Object();

// sleep 可以随便用
public void someMethod() {
    try {
        Thread.sleep(1000); // 没问题!
    } catch (InterruptedException e) {}
}

// wait 不能随便用
public void badWait() {
    // lock.wait(); // 直接报错!没在 synchronized 里
}

// wait 必须在 synchronized 里
public void goodWait() {
    synchronized (lock) {
        try {
            lock.wait(); // 正确:先拿锁
        } catch (InterruptedException e) {}
    }
}

如果你在 synchronized 外面调 wait(),JVM 会直接抛出:

java.lang.IllegalMonitorStateException

意思是:“你连锁都没有,凭什么说等等?”

底层原理:

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争

想象一个对象的锁(比如 synchronized(lock))是一个小屋子,门口有三个区域:

  1. Owner 区:正在屋子里干活的线程(持有锁)
  2. EntryList(入口等待区):一堆人排队等着进屋(BLOCKED 状态)
  3. WaitSet(等待区):一些人不在门口排队,而是去旁边椅子上坐着打盹(WAITING 状态)

这个“屋子”就是锁对象,比如 Object lock = new Object();

Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态

  • 有个线程正在屋子里干活(它是 Owner)
  • 它发现:“哎呀,条件不够,现在干不了”
  • 它说:“我先出去歇会儿,等别人告诉我好了再来”
  • 于是它走出屋子,坐到旁边的“等待椅”上(WaitSet),进入 WAITING 状态

BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片

  • BLOCKED:在门口排队的人,站着不动,不抢 CPU
  • WAITING:在休息区打盹的人,睡着了,也不抢 CPU
  • 他们都“暂停”了,操作系统不会分配时间片给他们执行

BLOCKED 线程会在 Owner 线程释放锁时唤醒

  • 一群人堵在门口(BLOCKED),等着进屋
  • 只要屋里的人(Owner)一出来(释放锁)
  • JVM 就会从队伍里选一个人进去(唤醒一个 BLOCKED 线程)

WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争

  • 打盹的人(WAITING)不会自己醒
  • 必须屋里的人喊一声:“可以干活了!”(notify()
  • 听到后,他从椅子上站起来 → 跑到门口 → 加入排队队伍(EntryList)
  • 但他要和其他人一起抢锁,不一定能马上进去!

注意:唤醒 ≠ 立刻执行!只是“可以开始抢了”,但还要排队!

代码优化

虚假唤醒:notify 只能随机唤醒一个 WaitSet 中的线程,这时如果有其它线程也在等待,那么就可能唤醒不了正确的线程

解决方法:采用 notifyAll

notifyAll 仅解决某个线程的唤醒问题,使用 if + wait 判断仅有一次机会,一旦条件不成立,无法重新判断

解决方法:用 while + wait,当条件不成立,再次 wait

@Slf4j(topic = "c.demo")
public class demo {
    static final Object room = new Object();
    static boolean hasCigarette = false;    //有没有烟
    static boolean hasTakeout = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (room) {
                log.debug("有烟没?[{}]", hasCigarette);
                while (!hasCigarette) {//while防止虚假唤醒
                    log.debug("没烟,先歇会!");
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                log.debug("有烟没?[{}]", hasCigarette);
                if (hasCigarette) {
                    log.debug("可以开始干活了");
                } else {
                    log.debug("没干成活...");
                }
            }
        }, "小南").start();

        new Thread(() -> {
            synchronized (room) {
                Thread thread = Thread.currentThread();
                log.debug("外卖送到没?[{}]", hasTakeout);
                if (!hasTakeout) {
                    log.debug("没外卖,先歇会!");
                    try {
                        room.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                log.debug("外卖送到没?[{}]", hasTakeout);
                if (hasTakeout) {
                    log.debug("可以开始干活了");
                } else {
                    log.debug("没干成活...");
                }
            }
        }, "小女").start();


        Thread.sleep(1000);
        new Thread(() -> {
        // 这里能不能加 synchronized (room)?
            synchronized (room) {
                hasTakeout = true;
				//log.debug("烟到了噢!");
                log.debug("外卖到了噢!");
                room.notifyAll();
            }
        }, "送外卖的").start();
    }
}

想象你在一个宿舍里:

  • 小南在等(他只认烟)
  • 小女在等外卖(她只认外卖)

现在送外卖的人来了,他喊了一声:“东西到了!”(notify())。但如果用的是 notify(),系统是随机叫一个人!可能叫到的是小南(等烟的),但来的是外卖,不是烟!小南醒了看一眼:“没烟啊?” → 但他不会再问了(因为是 if 判断),直接说:“我没活干。” 然后走了。结果:小女一直睡着,没人叫她,外卖白送了!这就是“唤醒了错误的人,正确的那个人却没醒”。

解决方法:用 notifyAll

送外卖的喊一声:“所有人给我醒醒!”(notifyAll()),小南和小女都醒来:

  • 小南一看:没烟 → 继续睡(wait()
  • 小女一看:有外卖!→ 开始吃饭,干活!

这样就能做到正确的人被唤醒。

为什么判断条件要用 while,不能用 if

if 就像:你设了个闹钟:“8点叫我起床”,闹钟响了,你睁开眼看了一眼窗外,天还没亮。你说:“哦,天没亮,我不起了。” 然后再也不看了,结果9点了你还在睡。

while 就像:闹钟响了,你看天没亮 → 继续睡;过会儿又被叫醒(别人喊你)→ 再看一眼;直到天亮了才起床。

所以:while 是“每次醒来都再检查一遍条件”,防止“醒早了”或“叫错人”。

park-un

LockSupport 是用来创建锁和其他同步类的线程原语

LockSupport 类方法:

  • LockSupport.park():暂停当前线程,挂起原语
  • LockSupport.unpark(暂停的线程对象):恢复某个线程的运行
public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("start...");	//1
		Thread.sleep(1000);// Thread.sleep(3000)
        // 先 park 再 unpark 和先 unpark 再 park 效果一样,都会直接恢复线程的运行
        System.out.println("park...");	//2
        LockSupport.park();
        System.out.println("resume...");//4
    },"t1");
    t1.start();
   	Thread.sleep(2000);
    System.out.println("unpark...");	//3
    LockSupport.unpark(t1);
}

LockSupport.park() 就像按了暂停键,让某个线程“卡住不动”;

LockSupport.unpark(线程) 就像按了播放键,让那个“被卡住”的线程继续跑。

你先按“播放”(unpark),再按“暂停”(park),也不会出问题——线程照样能继续跑。

public class ParkUnparkDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("1. start..."); // 1
            try {
                Thread.sleep(1000); // 睡1秒,让 main 线程先执行完前面的逻辑
            } catch (InterruptedException e) { }

            System.out.println("2. park..."); // 2
            LockSupport.park(); // 当前线程(t1)暂停!等别人叫醒我

            System.out.println("4. resume..."); // 4 被叫醒后才执行
        }, "t1");

        t1.start();

        Thread.sleep(2000); // 主线程睡2秒,确保 t1 已经执行到 park() 了

        System.out.println("3. unpark..."); // 3
        LockSupport.unpark(t1); // 唤醒 t1 线程
    }
}

控制台输出结果如下:

代码分析:

步骤 谁干的 干了啥 说明
1 t1 打印 start... t1 开始跑了
- t1 sleep(1000) 睡1秒
- main sleep(2000) main 线程也在睡
2 t1 打印 park...,然后 park() t1 暂停!卡在这儿不动了
3 main 打印 unpark...,然后 unpark(t1) main 叫醒 t1:“可以走了!”
4 t1 打印 resume... t1 被唤醒,继续往下走

关键点park() 会阻塞线程,直到有人调用 unpark(这个线程)

如果反过来:先 unpark,再 park 呢?

public class ParkUnparkReverse {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println("1. start...");
            try {
                Thread.sleep(3000); // 睡3秒,确保 main 的 unpark 已经执行了
            } catch (InterruptedException e) { }

            System.out.println("2. park...");
            LockSupport.park(); // 此时:unpark 已经执行过了

            System.out.println("3. resume..."); // 这句会立刻执行吗?
        }, "t1");

        t1.start();

        Thread.sleep(1000);
        System.out.println("unpark before park!");
        LockSupport.unpark(t1); // 先叫醒,但 t1 还没 park

        // t1 继续睡,直到 3秒后执行 park()
    }
}

控制台输出结果如下:

重点来了:为什么 park() 没有卡住?因为 unpark(t1) 提前发了一个“许可” 给 t1。

park() 相当于:“我看看有没有许可,有就直接过,没有就等。”因为之前已经 unpark 了,所以 park() 发现“哦,有许可”,直接放行,不阻塞。

所以得出结论:unpark 可以提前发“通行许可”,park 会先检查有没有许可,有就直接通过。

为什么说它是“创建锁和其他同步类的线程原语”?

“原语”就是最基础的零件,就像乐高最小的积木块。

Java 里的 synchronizedReentrantLockAQS(AbstractQueuedSynchronizer)这些高级锁,底层都是靠 park/unpark 来实现线程的等待和唤醒。

比如:

  • 加锁失败?→ park() 当前线程,让它等着。
  • 释放锁?→ unpark() 唤醒某个等待的线程。

它们不用 wait/notify,就是因为 park/unpark 更灵活、更底层、不会因为顺序出错而卡死。

特性 park/unpark wait/notify
是否需要 synchronized 不需要 必须配合 synchronized
是否能指定线程唤醒 可以 unpark(具体线程)  notify() 随机唤醒一个
先 unpark 再 park 会阻塞吗? 不会,有“许可”就过 不能先 notify,否则无效
更底层吗? 是,JUC 包的基石 是老的同步机制

LockSupport.park()unpark() 就像线程世界的“暂停键”和“播放键”,而且支持“提前播放”,是构建锁和并发工具的底层核心按钮。你可以把它理解为:操作系统级别的线程控制开关。

LockSupport 出现就是为了增强 wait & notify 的功能:

  • wait,notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park、unpark 不需要
  • park & unpark 以线程为单位来阻塞和唤醒线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify。类比生产消费,先消费发现有产品就消费,没有就等待;先生产就直接产生商品,然后线程直接消费
  • wait 会释放锁资源进入等待队列,park 不会释放锁资源,只负责阻塞当前线程,会释放 CPU

为什么 Java 要搞出 LockSupport.park()unpark()?它到底比老的 wait/notify 强在哪?

wait/notify 就像“广播喇叭 + 公共厕所”——你得先抢坑位(synchronized 锁),然后喊一声“我好了!”(notify),但不知道谁来接班。

park/unpark 就像“对讲机 + 私人司机”——我能点名叫醒谁就叫醒谁,不需要抢厕所,还能提前发“可以开工了”的信号。

对比维度一:是否需要“锁”才能用?

wait/notify:必须配合 synchronized(对象监视器)

synchronized (obj) {
    obj.wait();   // 必须先拿到 obj 的锁,否则报错!
    obj.notify(); // 同样要先拿到锁
}

就像你要上厕所,必须先开门进去(拿到锁),才能在里面喊“我好了!”。

park/unpark:不需要 synchronized

LockSupport.park();     // 随时可以暂停自己
LockSupport.unpark(t1); // 随时可以唤醒别人

就像你在马路上走路,突然累了,直接坐下休息(park);朋友看见了,走过来拍拍你说:“起来走啦!”(unpark),完全不用进厕所。

优势:更灵活,不依赖锁,适合做底层并发工具。

对比维度二:能不能“点名唤醒”?

wait/notify:notify 只能随机叫醒一个线程,不能指定

synchronized (obj) {
    obj.notify(); // 随机叫醒一个正在 wait(obj) 的线程
                  // 我也不知道是谁!
}

就像你在公司群里发:“可以来用了!”结果三个同事都在等,到底谁来?系统随机挑一个。

park/unpark:可以精确“点名道姓”唤醒某个线程

Thread t1 = new Thread(...);
Thread t2 = new Thread(...);

LockSupport.unpark(t1); // 我只想叫醒 t1
// t2 还在睡

就像你微信私聊:“小王,你来接班”,小李完全不知道这事。

优势:精准控制,避免无效唤醒,性能更高。

对比维度三:顺序能不能颠倒?先叫醒再暂停行不行?

wait/notify:不能先 notify,否则白搭!

// main 线程
synchronized (obj) {
    obj.notify(); // 现在没人 wait,这个 notify 就“浪费”了
}

// 之后 t1 才开始 wait
Thread t1 = new Thread(() -> {
    synchronized (obj) {
        try {
            obj.wait(); // 等一辈子也不会被叫醒!
        } catch (InterruptedException e) { }
    }
});

就像你早上6点喊“可以吃饭了!”,结果食堂7点才开门,没人听见,白喊。

park/unpark:可以先 unpark,再 park,照样有效!

Thread t1 = new Thread(() -> {
    try {
        Thread.sleep(2000); // 先睡2秒
    } catch (InterruptedException e) { }
    System.out.println("t1 准备暂停...");
    LockSupport.park(); // 发现:之前已经 unpark 过我了 → 直接通过!不阻塞!
    System.out.println("t1 被唤醒了 or 直接通过了");
});

t1.start();

// 先 unpark
LockSupport.unpark(t1); // 提前发“通行许可”
System.out.println("main: 我提前叫醒 t1 了");

就像你提前给司机发微信:“等下直接走,我已经说好了。”司机来了发现绿灯亮着,直接开车走人,不用等。

优势:不会因为顺序问题导致线程永久阻塞,更安全!

对比维度四:释放资源的区别

wait:会释放锁 + 释放 CPU

synchronized (obj) {
    obj.wait(); // 当前线程释放 obj 的锁,其他线程可以进入 synchronized 块
                // 同时释放 CPU,进入等待队列
}

就像你上完厕所不仅出来了,还把门钥匙交出去了,别人能进来。

park:不会释放锁,只释放 CPU

synchronized (this) {
    LockSupport.park(); // 线程暂停,但仍然持有当前 synchronized 锁!
                        // 其他线程进不来!
}

这意味着:其他线程无法进入这个 synchronized 块!就像你坐在马桶上突然发呆(park),人不动,但门还锁着,别人进不来。所以:park 一般不用在 synchronized 里!它本来就是给无锁或 AQS 这种高级玩法用的。

优势park 只负责“暂停线程”,不干涉锁逻辑,职责更单一,更适合做底层原语。

实战类比:生产者消费者模型

我们用一个“仓库放苹果”的例子对比:仓库最多放1个苹果

传统写法:wait/notify 版

Object lock = new Object();
boolean hasApple = false;

// 生产者
new Thread(() -> {
    synchronized (lock) {
        if (!hasApple) {
            System.out.println("生产一个苹果");
            hasApple = true;
            lock.notify(); // 喊一声:有苹果了!
        }
    }
}).start();

// 消费者
new Thread(() -> {
    synchronized (lock) {
        while (!hasApple) {
            try {
                lock.wait(); // 没苹果,等着
            } catch (InterruptedException e) { }
        }
        System.out.println("消费一个苹果");
        hasApple = false;
    }
}).start();

问题:

  • 必须 synchronized
  • notify 可能唤醒不了正确的线程(如果多个消费者)
  • 如果先 notify,消费者后 wait,就死等!

更加底层:park/unpark 版

Thread producer = null;
Thread consumer = null;

// 模拟原子变量
AtomicBoolean hasApple = new AtomicBoolean(false);

// 消费者线程
consumer = new Thread(() -> {
    if (!hasApple.get()) {
        System.out.println("消费者:没苹果,我先歇会...");
        LockSupport.park(); // 暂停自己
    }
    System.out.println("消费者:拿到苹果,开吃!");
    hasApple.set(false);
});

// 生产者线程
producer = new Thread(() -> {
    System.out.println("生产者:生产一个苹果");
    hasApple.set(true);
    LockSupport.unpark(consumer); // 精准叫醒消费者
});

consumer.start();
// 模拟消费者先启动并等待
Thread.sleep(1000);
producer.start();

优点:

  • 不需要 synchronized
  • 可以先 unpark(比如苹果已经放好了)
  • 精准唤醒 consumer

原理:类似生产者消费者

先 park:

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter ,本情况为 0,这时获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量挂起
  4. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  5. 唤醒 _cond 条件变量中的 Thread_0,Thread_0 恢复运行,设置 _counter 为 0

先 unpark:

  1. 调用 Unsafe.unpark(Thread_0) 方法,设置 _counter 为 1
  2. 当前线程调用 Unsafe.park() 方法
  3. 检查 _counter ,本情况为 1,这时线程无需挂起,继续运行,设置 _counter 为 0

安全分析

成员变量和静态变量:

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,分两种情况:
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全问题

局部变量:

  • 局部变量是线程安全的
  • 局部变量引用的对象不一定线程安全(逃逸分析):
    • 如果该对象没有逃离方法的作用访问,它是线程安全的(每一个方法有一个栈帧)
    • 如果该对象逃离方法的作用范围,需要考虑线程安全问题(暴露引用)

常见线程安全类:String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包

  • 线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

  • 每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全:

Hashtable table = new Hashtable();
// 线程1,线程2
if(table.get("key") == null) {
	table.put("key", value);
}

无状态类线程安全,就是没有成员变量的类

不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全

  • replace 等方法底层是新建一个对象,复制过去

Map<String,Object> map = new HashMap<>();	// 线程不安全
String S1 = "...";							// 线程安全
final String S2 = "...";					// 线程安全
Date D1 = new Date();						// 线程不安全
final Date D2 = new Date();					// 线程不安全,final让D2引用的对象不能变,但对象的内容可以变

抽象方法如果有参数,被重写后行为不确定可能造成线程不安全,被称之为外星方法:public abstract foo(Student s);

什么是线程安全?

想象你和你朋友在同一个银行账户上操作。你取钱,他也在取钱。如果系统没处理好,你们俩同时看到余额是 1000 元,你取了 500,他也取了 500,结果账户变成 -500?这就叫“线程不安全”。

线程安全就是:多个线程同时操作同一个东西时,不会出错、不会数据混乱。

成员变量和静态变量

如果它们没有共享,则线程安全

成员变量(类里的变量)和静态变量(static 变量)如果每个线程都有自己的副本,那它们就是安全的。

public class Counter {
    private int count = 0; // 成员变量

    public void add() {
        count++;
    }
}

如果每个线程都 new 一个自己的 Counter 对象:

Thread t1 = new Thread(() -> {
    Counter c1 = new Counter();
    c1.add();
});

Thread t2 = new Thread(() -> {
    Counter c2 = new Counter(); // 和 c1 不是同一个对象
    c2.add();
});

这两个线程用的是不同的对象,所以 count 不共享 → 线程安全

如果它们被共享了,根据它们的状态是否能够改变,分两种情况:

如果多个线程都在用同一个对象的成员变量或静态变量,那就要看这个变量能不能被改。

如果只有读操作,则线程安全

大家都只看,不改,那没问题,就像很多人同时看一本书的内容,不会乱。

public class Config {
    private String version = "1.0"; // 共享的成员变量

    public String getVersion() {
        return version; // 只读
    }
}
  • 多个线程调用 getVersion() → 只读 → 线程安全

如果有读写操作,则这段代码是临界区,需要考虑线程安全问题

如果多个线程又读又写同一个变量,就像两个人同时改一个文档,必须上锁,否则数据会乱。

public class Counter {
    private int count = 0;

    public void add() {
        count++; // 读 + 写(不是原子操作!)
    }
}

  • 两个线程同时调用 add()
    • 线程A读到 count=5
    • 线程B也读到 count=5
    • A写入6,B也写入6 → 实际只加了一次 → 出错了
    • 这种“可能出错的区域”叫临界区,必须加锁

局部变量

局部变量是线程安全的

方法里的变量,每个线程调用时都会在自己的“栈”里创建一份,互不影响。也就是线程安全的。

public void calculate() {
    int temp = 10;       // 局部变量
    temp = temp + 5;
    System.out.println(temp);
}
  • 线程A调用 calculate(),有自己的 temp
  • 线程B调用,也有自己的 temp
  • 互不干扰 → 线程安全

局部变量引用的对象不一定线程安全(逃逸分析)

如果该对象没有逃离方法的作用范围,它是线程安全的(每一个方法有一个栈帧)

虽然变量是局部的,但如果它指向的对象被“放出去”了(比如返回、传给别的方法),那就可能被多个线程访问,就不安全了。但是只要这个对象只在方法内部用,用完就扔,每个线程各玩各的,就安全。

public void processData() {
    List<String> list = new ArrayList<>(); // 局部变量,对象也在方法内
    list.add("hello");
    list.add("world");
    // list 没有被返回,也没有传给别的线程
}
  • 每个线程调用这个方法,都有自己独立的 list → 安全

如果该对象逃离方法的作用范围,需要考虑线程安全问题(暴露引用)

如果你把这个对象“交出去”了(比如返回、存到公共地方),别人也能改它,那就可能不安全。

List<String> globalList = new ArrayList<>(); // 公共的,可能被多个线程访问

public void addData() {
    List<String> list = new ArrayList<>();
    list.add("data");
    globalList.addAll(list); // 把局部对象的内容“暴露”出去了
}
  • 虽然 list 是局部的,但它指向的数据被加到了 globalList
  • globalList 是共享的 → 多个线程都往里加 → 可能线程不安全

这就是“逃逸”了。

常见线程安全类

String、Integer、StringBuffer、Random、Vector、Hashtable、java.util.concurrent 包,这些类的设计已经考虑了多线程,你可以放心让多个线程同时用同一个实例。

String s = "hello";
Integer i = 100;
StringBuffer sb = new StringBuffer();
Vector<String> vec = new Vector<>();
Hashtable<String, String> table = new Hashtable<>();
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
  • 多个线程同时调用 sb.append("x") → 安全(StringBuffer 是同步的)
  • 多个线程同时 put 到 table → 安全(Hashtable 方法加了锁)

但注意:StringBuilder不安全!它没加锁,速度快,但多线程不能用。

线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的

比如 Hashtable,你创建一个实例,10 个线程同时调用它的 put() 方法,不会出问题。Hashtable 内部加了锁,保证线程安全。

Hashtable<String, Integer> table = new Hashtable<>();

// 线程1
new Thread(() -> {
    table.put("a", 1);
}).start();

// 线程2
new Thread(() -> {
    table.put("b", 2);
}).start();

每个方法是原子的,但多个方法的组合不是原子的,只能保证调用的方法内部安全

单个方法是安全的,但多个方法连着调用就不一定了,中间可能被别的线程插队。

Hashtable<String, Object> table = new Hashtable<>();

// 线程1 和 线程2 同时执行:
if (table.get("key") == null) {
    table.put("key", "value"); // 问题在这里!
}
  • 线程A:get("key") → null → 进入 if
  • 线程B:get("key") → null → 也进入 if
  • A 执行 put("key", "value")
  • B 也执行 put("key", "value") → 覆盖了!而且本来只想 put 一次

getput 各自是安全的,但组合起来不是原子的

正确做法:用 putIfAbsent() 或加锁。

无状态类 & 不可变类

无状态类线程安全,就是没有成员变量的类

一个类啥变量都没有,只有一些方法,那它肯定是线程安全的,因为没有东西可以被改。

public class MathUtils {
    public int add(int a, int b) {
        return a + b; // 只用参数,没有成员变量
    }
}
  • 在这个例子中,多个线程调用 add() 是安全的。

不可变类线程安全:String、Integer 等都是不可变类,内部的状态不可以改变,所以方法是线程安全

不可变类:一旦创建,内容就不能改。比如 String s = "abc"; s.toUpperCase() 返回一个新字符串,原来的没变。

String s = "hello";

// 线程1
new Thread(() -> {
    String upper = s.toUpperCase();
}).start();

// 线程2
new Thread(() -> {
    String lower = s.toLowerCase();
}).start();

安全!因为 s 本身没变,只是生成了新对象。 replacesubstring 等方法都是这样:不改自己,新建一个对象返回

final 和线程安全

写法 是否线程安全 为什么
Map map = new HashMap<>() 不安全 HashMap 本身不是线程安全的
String S1 = "..." 安全 String 是不可变类
final String S2 = "..." 安全 final + 不可变类,更安全
Date D1 = new Date() 不安全 Date 是可变的,多个线程改它会乱
final Date D2 = new Date() 依然不安全 final 只保证 D2 不能再指向别的对象,但 Date 对象本身的内容可以被改(比如 setTime()

final 不等于线程安全!它只锁住“引用”,不锁住“对象内容”。

抽象方法与“外星方法”

抽象方法如果有参数,被重写后行为不确定可能造成线程不安全,被称之为外星方法:public abstract foo(Student s);

“外星方法”是你控制不了的方法,比如别人重写的抽象方法,你不知道它是线程安全的还是不安全的。

public abstract class Processor {
    public void process(Student s) {
        validate(s);
        foo(s); // 外星方法!你不知道子类怎么实现的
        log(s);
    }

    public abstract void foo(Student s); // 子类自由发挥
}

写了个 process() 方法,看起来没问题;但 foo(s) 是抽象的,子类可能这样实现:

public void foo(Student s) {
    sharedList.add(s); // 访问共享变量,而且没加锁
}

多个线程调用process() → foo() 可能导致线程不安全。调用“外星方法”时要特别小心,尤其是在同步代码块中。

    Logo

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

    更多推荐