本篇参考书籍为JaveEE零基础入门/史胜辉,王春明,沈学华编著.—北京:清华大学出版社,2021.1(2022.8重印) ISBN 978-7-302-56938-1
前言
大家好!今天让我们一起揭开Java多线程同步中的神秘面纱,特别是那个至关重要的关键字——synchronized。我们将从多线程的基础知识开始,逐步深入理解synchronized的工作原理,并通过一些生活化的例子帮助大家更好地消化这些概念。
一、多线程的生命周期与基本概念
首先,我们先回顾一下多线程的基本概念。在Java程序中,多线程是指在一个进程中可以同时执行多个不同任务的能力。每个任务作为一个独立的执行流(线程)存在,它们共享进程的内存空间,但各自拥有独立的执行上下文和堆栈。具体可以回顾一下我之前写的一篇,链接: 多线程的概念以及如何创建线程.
在Java程序中,每个线程都有一段生命周期,包括新建(new)、就绪(runnable)、运行(running)、阻塞(blocked)、等待(waiting)、超时等待(timed waiting)和终止(terminated)状态。
1、新建(New):当使用new关键字创建一个线程对象时,线程处于新建状态。此时,线程对象还未被启动,start()方法尚未被执行。
2、就绪(Runnable):当调用线程对象的start()方法后,线程进入就绪状态。此时线程已经准备好运行,但还没有分配到CPU时间片。线程位于操作系统的就绪队列中,等待被操作系统调度执行。
3、运行(Running):当线程获得CPU时间片后,开始执行run()方法,此时线程处于运行状态。需要注意的是,即使线程在运行,也可能因为线程调度的原因暂时让渡CPU给其他线程,随后再次回到运行状态。
4、阻塞(Blocked/Waiting):当线程执行过程中遇到以下情况时,会从运行状态转换为阻塞状态:
线程调用了sleep()、wait()、join()等方法;
线程尝试获取一个被其他线程持有的锁(即进入synchronized代码块或方法);
线程在等待某个I/O操作完成;
线程在等待某个条件满足(如Condition.await()方法)。
5、超时等待(Timed Waiting):类似于阻塞状态,但线程在等待一定时间后会自动恢复到就绪状态,例如调用了sleep(long millis)、wait(long timeout)、Future.get(long timeout, TimeUnit unit)等带超时参数的方法。
6、终止(Terminated):线程执行结束(run()方法执行完毕),或者主线程调用了Thread.stop()(已废弃,不推荐使用)、Thread.interrupt()(中断线程)等方法,线程会进入终止状态。终止状态的线程无法再次被启动。
当多个线程同时存在于一个程序中时,它们会在CPU调度下交替执行,共同完成一项或多项任务。
举个栗子:想象一下,一个厨房里有几位厨师(线程)同时做菜,他们分别有自己的案板和厨具(线程的运行资源)。如果多位厨师都需要同一只汤锅(共享资源),如果不加以协调,可能会出现两位厨师同时往锅里加料,导致混乱不堪的局面。在Java多线程中,这种资源共享的问题就需要借助synchronized来解决。
二、竞态条件与线程安全
竞态条件就像是上面提到的厨师争夺汤锅的例子。在多线程环境下,如果多个线程同时读取或修改共享数据,没有适当的保护措施,就可能出现不可预测的结果,这就是竞态条件。
解决这个问题的关键就是保证在同一时刻只有一个线程访问特定的资源,这就是“线程同步”的核心所在。而在Java中,synchronized关键字正是用于实现这一目标的关键工具。
三、synchronized关键字的使用
synchronized关键字有两种基本使用方式:同步方法和同步代码块。
1、同步方法:
public class CounterKitchen {
private int spoonCount = 0;
// 把整个"烹饪"过程(方法)上锁
public synchronized void useSpoon() {
spoonCount++;
System.out.println("正在使用的勺子数量:" + spoonCount);
// 做菜的过程...
spoonCount--;
System.out.println("归还勺子,现在勺子数量:" + spoonCount);
}
}
在这个例子中,useSpoon()方法被 synchronized修饰,意味着任何时刻只能有一个线程能执行这个方法,就像只能有一位厨师在使用汤锅一样。
2、同步代码块:
尽管可以在创建类时,把访问共享资源的方法定义为同步方法,实现线程对共享资源同步,但是这种方法并不是一直有效。例如:程序中调用了一个第三方类库中某个类的方法,无法获得该类库的源代码,这样,无法在相关方法前添加synchronized关键字。那怎么解决这个问题呢?通过使用同步代码块可以解决这个问题。
public class CounterKitchen {
private int spoonCount = 0;
private Object spoonBox = new Object(); // 我们创建一个"勺子箱"作为锁对象
public void useSpoon() {
// 只对特定的"拿勺子和放勺子"操作上锁
synchronized (spoonBox) {
spoonCount++;
System.out.println("正在使用的勺子数量:" + spoonCount);
// 做菜的过程...
spoonCount--;
System.out.println("归还勺子,现在勺子数量:" + spoonCount);
}
}
}
同步代码块更加灵活,我们可以明确指定哪个对象作为“锁”。当一个线程进入同步代码块并获取到锁之后,其他试图获取同样锁的线程必须等待,直到第一个线程执行完毕并释放锁。
通俗解释:这里的“锁住”就像是厨师在使用汤锅前先在锅上挂一个小牌子写着“正在使用,请稍候”。其他看到牌子的厨师就不能再去操作这只汤锅,直到挂着牌子的厨师做完菜并将牌子取下。
四、哲学家就餐问题(经典并发编程示例)
class ChopStick{
boolean available;
// ChopStick构造方法,初始化时设置筷子为可用状态
ChopStick(){
available = true;
}
// 同步方法takeup,用于模拟哲学家拿起筷子的动作
// 当筷子不可用时,哲学家调用此方法会陷入等待,直至筷子变为可用
public synchronized void takeup() {
while(!available) {
try {
System.out.println("哲学家等待另一根筷子");
wait();
}catch(InterruptedException e) {}
}
available = false;
}
// 同步方法putdown,用于模拟哲学家放下筷子的动作
// 当调用此方法时,会将筷子设置为可用,并唤醒一个等待该筷子的哲学家
public synchronized void putdown() {
available = true;
notify();
}
}
// 定义一个哲学家类,继承自Thread类,代表一个并发执行的哲学家线程
class Philosopher extends Thread{
ChopStick left,right;
int phio_num;// 哲学家编号
// 哲学家构造方法,传入左右筷子对象和哲学家编号
public Philosopher(ChopStick left,ChopStick right,int phio_num) {
this.left = left;
this.right = right;
this.phio_num = phio_num;
}
// 模拟哲学家吃饭的动作,先依次拿起左右两边的筷子
public void eat() {
left.takeup();
right.takeup();
System.out.println("哲学家"+(this.phio_num+1)+"在用餐");
}
// 模拟哲学家思考的动作,先放下手中的筷子
public void think() {
left.putdown();
right.putdown();
System.out.println("哲学家"+(this.phio_num+1)+"在思考");
}
// 重写Thread类的run方法,定义哲学家线程的行为
// 哲学家线程不断在吃饭和思考之间切换
public void run() {
while(true) {
eat();
try {
sleep(1000);
}catch(InterruptedException e) {}
think();
try {
sleep(1000);
}catch(InterruptedException e) {}
}
}
}
// 主类,用于创建和启动哲学家线程
public class demoWaitNoit {
public static void main(String[] args) {
// TODO Auto-generated method stub
final ChopStick[] chopsticks = new ChopStick[4];// 创建4根筷子对象,存放在chopsticks数组中
final Philosopher[] philos = new Philosopher[4];// 创建4个哲学家对象,存放在philos数组中
// 初始化筷子对象
for(int i=0;i<4;i++) {
chopsticks[i] = new ChopStick();
}
// 创建哲学家,给每个哲学家分配左右两根筷子
for(int i=0;i<4;i++) {
philos[i] = new Philosopher(chopsticks[i],chopsticks[(i+1)%4],i);
}
// 启动所有哲学家线程
for(int i=0;i<4;i++) {
philos[i].start();
}
}
}
该问题描述了四个哲学家围绕一张圆桌坐着,每两个哲学家之间放着一根筷子,桌子上共有四根筷子。每个哲学家需要同时拿起左右两根筷子才能吃饭。如果所有的哲学家同时尝试拿起筷子,则可能会发生死锁,即所有哲学家都在等待其他哲学家放下手中的筷子。
首先定义了一个ChopStick(筷子)类,它有两个关键方法:
takeup():用于拿起筷子。方法被声明为synchronized,意味着同一时间只能有一个线程调用此方法。方法内部通过一个循环和wait()方法实现,只有当筷子可用(available为true)时,哲学家才能拿起筷子;否则,哲学家会进入等待状态并释放当前锁,直到被其他线程唤醒。
putdown():用于放下筷子。同样为synchronized方法。当哲学家吃完饭后,调用此方法将筷子设为可用,并调用notify()方法唤醒一个正在等待此筷子的哲学家。
接着定义了一个Philosopher(哲学家)类,它是Thread类的子类,具有自己的编号和左右两根筷子引用:
构造函数接收两根筷子和哲学家编号作为参数。
eat()方法模拟哲学家吃饭的动作,先依次拿起左右两边的筷子,然后输出吃饭信息。
think()方法模拟哲学家思考的动作,先放下手中的筷子,然后输出思考信息。
run()方法是线程运行的核心逻辑,无限循环地进行吃饭和思考的操作,每次动作之间有1秒的延时(通过sleep()实现)。
最后在main()方法中:
创建了5根筷子的数组chopsticks。
根据筷子的位置关系创建了4个哲学家对象并存储在philos数组中。
启动所有哲学家线程,让他们开始竞争筷子并按照指定逻辑进行吃饭和思考。
整个程序展示了如何利用Java中的synchronized关键字和wait()、notify()方法解决哲学家就餐问题中的死锁现象。通常情况下,对于此类问题还需要更复杂的解决方案,例如采用信号量或者条件变量来更加精确控制资源的分配与回收,避免饥饿等问题的发生。但在此简单示例中,已经能体现出基本的同步机制和避免死锁的基本思路。
总结
synchronized关键字帮助我们在多线程环境中维持数据的一致性和正确性,防止了竞态条件的发生。然而,过度或不当使用synchronized可能导致性能下降,比如过多的线程争抢同一把锁可能会造成“线程饥饿”或“活锁”等问题。因此,在实际开发中,我们需要根据具体业务需求,合理选择线程同步机制,比如使用显式锁(ReentrantLock)、信号量(Semaphore)或者原子类(Atomic)等工具,以实现更高的并发效率。