线程同步

在单线程程序中,每次只能做一件事情,后面的事情需要等待前面的事情完成后才可以进行,但是如果使用多线程程序,就会发生两个线程抢占资源的问题,如两个人同时说话、两个人同时过同一座独木桥等,因此在多线程编程中需要防止这些资源访问的冲突。Java 提供了线程同步的机制来防止资源访问的冲突。

线程安全

实际开发中,使用多线程程序的情况很多,如银行排号系统、火车站售票系统等。这种多线程的程序通常会发生问题。以火车站售票系统为例,在代码中判断当前票数是否大于0。如果大于0,则执行将该票出售给乘客的功能,但当两个线程同时访问这段代码时(假如这时只剩下一张票),第一个线程将票售出,与此同时第二个线程也已经执行完成判断是否有票的操作,并得出票数大于0的结论,于是它也执行售出操作,这样就会产生负数。因此,在编写多线程程序时,应该考虑线程安全问题。实质上线程安全问题来源于两个线程同时存取单一对象的数据。

例如,在项目中创建 ThreadSafeTest 类,该类实现 Runnable 接口,在未考虑到线程安全问题的基础上,模拟火车站售票系统的功能的代码如下:

public class ThreadSafeTest implements Runnable {
    int num = 10;               // 设置当前总票数

    public void run() {
        while (true) {          // 设置无限循环
            if (num > 0) {      // 判断当前票数是否大于0
                try {
                    Thread.sleep(100);      // 使当前线程休眠100毫秒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "----票数" + num--);  // 票数减1
            }
        }
    }

    public static void main(String[] args) {
        ThreadSafeTest t = new ThreadSafeTest();    // 实例化类对象
        Thread tA = new Thread(t, "线程一");    // 以该类对象分别实例化4个线程
        Thread tB = new Thread(t, "线程二");
        Thread tC = new Thread(t, "线程三");
        Thread tD = new Thread(t, "线程四");
        tA.start();                                  // 分别启动线程
        tB.start();
        tC.start();
        tD.start();
    }
}

运行本实例,最后几行结果如图20.9所示。

从这个结果中可以看出,最后输出的剩下的票数为负值,这样就出现了问题。这是由于同时创建了4个线程,这4个线程都执行 run() 方法,在 num 变量为1时,线程一、线程二、线程三、线程四都对 num 变量有存储功能,当线程一执行 run() 方法时,还没有来得及做递减操作,就指定它调用 sleep() 方法进入就绪状态,这时线程二、线程三和线程四也都进入了 run() 方法,发现 num 变量依然大于 0,但此时线程一休眠时间已到,将 num 变量值递减,同时线程二、线程三、线程四也都对 num 变量进行递减操作,从而产生了负值。

image 2024 03 06 11 42 44 004
Figure 1. 图20.9 资源共享冲突后出现的问题

线程同步机制

那么,该如何解决资源共享的问题呢?所有解决多线程资源冲突问题的方法基本上都是采用给定时间只允许一个线程访问共享资源的方法,这时就需要给共享资源上一道锁。这就好比一个人上洗手间时,他进入洗手间后会将门锁上,出来时再将锁打开,然后其他人才可以进入。

同步块

Java 中提供了同步机制,可以有效地防止资源冲突。同步机制使用 synchronized 关键字,使用该关键字包含的代码块被称为同步块,也将其称为临界区,语法如下:

synchronized(Object) {

}

通常将共享资源的操作放置在 synchronized 定义的区域内,这样当其他线程获取到这个锁时,就必须等待锁被释放后才可以进入该区域。Object 为任意一个对象,每个对象都存在一个标志位,并具有两个值,分别为 0 和 1。一个线程运行到同步块时首先检查该对象的标志位,如果为 0 状态,则表明此同步块内存在其他线程,这时当期线程处于就绪状态,直到处于同步块中的线程执行完同步块中的代码后,这时该对象的标识位被设置为 1,当期线程才能开始执行同步块中的代码,并将 Object 对象的标识位设置为 0,以防止其他线程执行同步块中的代码。

【例20.7】开发线程安全的火车售票系统(实例位置:资源包\TM\sl\20\7)

创建 SynchronizedTest 类,修改之前线程不安全的火车售票系统,把对 num 操作的代码设置在同步块中。

public class SynchronizedTest implements Runnable {
    int num = 10; // 设置当前总票数

    public void run() {
        while (true) { // 设置无限循环
            synchronized (this) { // 设置同步代码块
                if (num > 0) { // 判断当前票数是否大于0
                    try {
                        Thread.sleep(100); // 使当前线程休眠100毫秒
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    // 票数减1
                    System.out.println(Thread.currentThread().getName() + "——票数" + num--);
                }
            }
        }
    }

    public static void main(String[] args) {
        // 实例化类对象
        SynchronizedTest t = new SynchronizedTest();
        // 以该类对象分别实例化4个线程
        Thread tA = new Thread(t, "线程一");
        Thread tB = new Thread(t, "线程二");
        Thread tC = new Thread(t, "线程三");
        Thread tD = new Thread(t, "线程四");
        tA.start(); // 分别启动线程
        tB.start();
        tC.start();
        tD.start();
    }
}

运行结果如下:

     线程一——票数10
     线程一——票数9
     线程一——票数8
     线程一——票数7
     线程一——票数6
     线程一——票数5
     线程一——票数4
     线程一——票数3
     线程一——票数2
     线程一——票数1

从这个结果中可以看出,输出到最后票数没有出现负数,这是因为将共享资源放置在了同步块中,不管程序如何运行都不会出现负数。

同步方法

同步方法就是在方法前面用 synchronized 关键字修饰的方法,其语法如下:

synchronized void f() {}

当某个对象调用了同步方法时,该对象上的其他同步方法必须等待该同步方法执行完毕后才能被执行。必须将每个能访问共享资源的方法修饰为 synchronized,否则就会出错。

修改例20.7的代码,将共享资源操作放置在一个同步方法中,代码如下:

int num = 10;
public synchronized void doit() {   // 定义同步方法
    if (num > 0) {
        try {
            Thread.sleep(10);
        } catch(InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + "-----票数" + num--);
    }
}

public void run() {
    while(true) {
        doit();            // 在run()方法中调用该同步方法
    }
}

将共享资源的操作放置在同步方法中,运行结果与使用同步块的结果一致。

编程训练(答案位置:资源包\TM\sl\20\编程训练)

【训练7】水池放水 创建一个容量为100升的水池,在水池上设置3个出水口,A出水口每秒排出1升水,B出水口一秒排出2升水,C出水口一秒排出3升水,使用线程模拟3个出水口同时排水的场景,并计算出多少秒后水池的水会被排光。

【训练8】模拟敲击键盘 使用IO流按字节读取文件并通过线程的休眠控制读取字节的速度,再将读取的字节显示在文本域中,最后使用synchronized关键字实现暂停读取和继续读取的功能。