首页 > 编程笔记 > Java笔记 阅读:2

Java线程同步的多种实现方法(非常详细)

线程安全问题主要是指多个线程同时访问和操作同一共享资源时,可能导致数据的不一致性、程序的不可预测性,以及错误的结果。

【实例 1】火车站票务系统模拟多窗口并行售票流程。
为了模拟火车站的票务销售过程,我们将构建一个场景,其中本次列车的座位总数为 5 个(火车票的最大销售数量为 5 张)。在此框架下,我们将模拟车站的售票窗口功能,特别是多个售票窗口同时运行的情况,以确保票务分配高效且准确。

重要提示,在模拟过程中,需要严格遵循票务的唯一性和准确性原则,以避免任何形式的错票或重票现象发生,代码如下:
public class WindowDemo extends Thread {
    private int ticket = 5;

    @Override
    public void run() {
        while (ticket > 0) {
            System.out.println(getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SafeTicketDemo {
    public static void main(String[] args) {
        WindowDemo w1 = new WindowDemo();
        WindowDemo w2 = new WindowDemo();
        WindowDemo w3 = new WindowDemo();

        // 给线程定义名称
        w1.setName("窗口 1");
        w2.setName("窗口 2");
        w3.setName("窗口 3");

        // 启动线程
        w1.start();
        w2.start();
        w3.start();
    }
}
程序运行结果为:
窗口2卖出一张票,票号:5
窗口2卖出一张票,票号:4
窗口2卖出一张票,票号:3
窗口2卖出一张票,票号:2
窗口2卖出一张票,票号:1
窗口3卖出一张票,票号:5
窗口3卖出一张票,票号:4
窗口3卖出一张票,票号:3
窗口3卖出一张票,票号:2
窗口3卖出一张票,票号:1
窗口1卖出一张票,票号:5
窗口1卖出一张票,票号:4
窗口1卖出一张票,票号:3
窗口1卖出一张票,票号:2
窗口1卖出一张票,票号:1
在上述代码中,局部变量不可共享,若发现售出 15 张票的情况,则需要明确局部变量在每次方法调用时均保持独立状态。因此,每个线程的 run() 方法中的 ticket 变量也各自独立,不存在数据共享问题。同样,不同实例对象的实例变量各自独立,不相互共享数据。

在实例 1 的基础上,尝试使用静态变量实现数据共享。

【实例 2】在实例 1 的基础上进行数据共享,代码如下:
public class WindowDemo02 extends Thread {
    private static int ticket = 5;

    public void run() {
        while (ticket > 0) {
            try {
                Thread.sleep(10); // 加入这个命令,使问题暴露得更明显
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SafeTicketDemo02 {
    public static void main(String[] args) {
        WindowDemo02 w1 = new WindowDemo02();
        WindowDemo02 w2 = new WindowDemo02();
        WindowDemo02 w3 = new WindowDemo02();

        // 给线程定义名称
        w1.setName("窗 1");
        w2.setName("窗 2");
        w3.setName("窗 3");

        // 启动线程
        w1.start();
        w2.start();
        w3.start();
    }
}
程序运行结果为:
窗口1卖出一张票,票号:5
窗口2卖出一张票,票号:5
窗口3卖出一张票,票号:5
窗口3卖出一张票,票号:2
窗口1卖出一张票,票号:2
窗口2卖出一张票,票号:2
窗口3卖出一张票,票号:-1
在上述代码中,我们发现已经售出近 5 张票,采用静态变量方式实现数据共享。然而,这一方法导致重复票或负数票问题,暴露了线程安全隐患。

在实例 2 的基础上,尝试使用同一个对象的实例变量共享。

【实例 3】在实例 2 的基础上,实现对同一对象实例变量的共享使用,代码如下:
public class WindowDemo03 implements Runnable {
    private int ticket = 5;

    public void run() {
        while (ticket > 0) {
            try {
                Thread.sleep(10); // 加入这个命令,使问题暴露得更明显
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SafeTicketDemo03 {
    public static void main(String[] args) {
        // 创建线程任务对象
        WindowDemo03 tr = new WindowDemo03();
        // 定义线程对象
        Thread t1 = new Thread(tr, "窗 1");
        Thread t2 = new Thread(tr, "窗 2");
        Thread t3 = new Thread(tr, "窗 3");
        // 启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}
程序运行结果为:
窗口1卖出一张票,票号:5
窗口2卖出一张票,票号:5
窗口3卖出一张票,票号:5
窗口1卖出一张票,票号:2
窗口3卖出一张票,票号:1
窗口2卖出一张票,票号:1
窗口1卖出一张票,票号:-1
在上述代码中,我们发现卖出近 5 张票,但有重复票或负数票问题,依然存在线程安全问题,下一节将讲述如何解决线程安全问题。

Java线程同步机制的实现

要解决上节提到的多线程并发访问一个资源的安全性问题,也就是解决重复票与不存在票(负数票)的问题,Java 提供了线程同步机制,如下图所示。


图 1 Java线程同步机制

关键字 synchronized 可以用于某个区块前面,表示只对这个区块的资源实行互斥访问。语法结构如下:
synchronized(同步锁){
    // 需要同步操作的代码
}

关键字 synchronized 还可以直接修饰方法,表示同一时刻只有一个线程能够进入这个方法,其他线程在外面等待。语法结构如下:
public synchronized void method() {
    // 可能产生线程安全问题的代码
}
同步锁对象可以是任意类型,但必须保证竞争“同一个共享资源”的多个线程必须使用同一个“同步锁对象”。

对同步代码块来说,同步锁对象是由程序员手动指定的(很多时候也是指定为 this 或类名.class),但对同步方法来说,同步锁对象只能是默认的静态方法[当前类的Class对象(类名.class)]和非静态方法(this)。

1) 同步方法

① 在静态方法上添加同步锁。

【实例 4】在基于例 3 的情境下,建立数据共享机制,以有效应对并解决线程安全问题,代码如下:
public class WindowDemo04 extends Thread {
    private static int ticket = 5;

    public void run() { // 直接锁这里,肯定不行,会导致只有一个窗口卖票
        while (ticket > 0) {
            saleOneTicket();
        }
    }

    public synchronized static void saleOneTicket() {
        // 锁对象是 WindowDemo04 类的 Class 对象,而一个类的 Class 对象在内存中肯定只有一个
        if (ticket > 0) { // 不加条件,相当于条件判断没有进入锁管控,线程安全问题没有解决
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SafeTicketDemo04 {
    public static void main(String[] args) {
        // 创建线程对象
        WindowDemo04 t1 = new WindowDemo04();
        WindowDemo04 t2 = new WindowDemo04();
        WindowDemo04 t3 = new WindowDemo04();

        // 设置线程名称
        t1.setName("窗 1");
        t2.setName("窗 2");
        t3.setName("窗 3");

        // 启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}
程序运行结果为:
窗口1卖出一张票,票号:5
窗口2卖出一张票,票号:4
窗口2卖出一张票,票号:3
窗口2卖出一张票,票号:2
窗口2卖出一张票,票号:1
在上述代码中,建立了数据共享机制,通过将 synchronized 同步锁用于静态方法之上,巧妙地解决了线程安全问题,确保了数据的一致性和完整性。

② 在非静态方法上添加同步锁。

【实例 5】在基于例 3 的情境下,建立数据共享机制,以有效应对并解决线程安全问题,代码如下:
public class WindowDemo05 implements Runnable {
    private static int ticket = 5;

    public void run() { // 直接锁这里,肯定不行,会导致只有一个窗口卖票
        while (ticket > 0) {
            saleOneTicket();
        }
    }

    public synchronized void saleOneTicket() {
        // 锁对象是 this,这里就是 WindowDemo05 对象,因为上面三个线程使用同一个
        // WindowDemo05 对象,所以可以
        if (ticket > 0) { // 不加条件,相当于条件判断没有进入锁管控,线程安全问题就没有解决
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        }
    }
}

public class SafeTicketDemo05 {
    public static void main(String[] args) {
        // 创建线程任务对象
        WindowDemo05 tr = new WindowDemo05();
        // 创建线程类
        Thread t1 = new Thread(tr, "窗 1");
        Thread t2 = new Thread(tr, "窗 2");
        Thread t3 = new Thread(tr, "窗 3");
        // 启动线程
        t1.start();
        t2.start();
        t3.start();
    }
}
在上述代码中,建立了数据共享机制,通过将 synchronized 同步锁用于非静态方法之上,巧妙地解决了线程安全问题,确保了数据的一致性和完整性。

2) 同步代码块

将 synchronized 同步锁应用在代码块上,解决了线程安全问题,确保了数据的一致性和完整性。

【实例 6】在基于例 3 的情境下,实现数据共享机制,以有效应对并解决线程安全问题,代码如下:
public class WindowDemo06 {
    private static int ticket = 5;

    public void sale() { // 也可以直接给这个方法加锁,锁对象是 this,这里就是 WindowDemo06 对象
        if (ticket > 0) {
            System.out.println(Thread.currentThread().getName() + "卖出一张票,票号:" + ticket);
            ticket--;
        } else {
            throw new RuntimeException("没有票了");
        }
    }

    public int getTicket() {
        return ticket;
    }
}

public class SafeTicketDemo06 {
    public static void main(String[] args) {
        // 创建资源对象
        WindowDemo06 ticket = new WindowDemo06();
        // 启动多个线程操作资源类的对象

        Thread t1 = new Thread("窗 1") {
            @Override
            public void run() { // 不能给 run() 直接加锁,因为 t1、t2、t3 的三个 run 方法分别属于三个
                // Thread 类对象
                // run 方法是非静态方法,那么锁对象默认选 this,那么锁对象根本不是同一个
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        };

        Thread t2 = new Thread("窗 2") {
            @Override
            public void run() {
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        };

        Thread t3 = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    synchronized (ticket) {
                        ticket.sale();
                    }
                }
            }
        }, "窗口3");

        t1.start();
        t2.start();
        t3.start();
    }
}
在上述代码中,建立了数据共享机制,通过将 synchronized 同步锁用于代码块之上,巧妙地解决了线程安全问题,确保了数据的一致性和完整性。

当窗口 1 的线程开始进行操作时,窗口 2 和窗口 3 的线程需要在外部等待。直到窗口 1 的操作完成,窗口 1、窗口 2 与窗口 3 的线程才有机会进入代码执行阶段。

换言之,在某个线程对共享资源进行修改时,其他线程必须等待该修改完成并实现同步后,方可去竞争 CPU 资源,完成各自的操作。这样的机制确保了数据的同步性,从而解决了线程不安全的问题。

为确保每个线程都能正确执行原子操作,Java 引入了线程同步机制。特别值得注意的是,无论任何时候,都只能有一个线程持有同步锁。获得同步锁的线程将进入代码块执行阶段,而其他线程处于阻塞状态,等待同步锁的释放。

3) 同步锁(Lock)

java.util.concurrent.locks.Lock 机制提供了比 synchronized 代码块和 synchronized 方法更广泛的锁定操作,同步代码块/同步方法具有的功能同步锁都有,同步锁的功能更强大,更体现面向对象。

同步锁使加锁和释放锁方法化:
【实例 7】在基于例 3 的情境下,建立数据共享机制,以有效应对并解决线程安全问题,代码如下:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class TicketDemo implements Runnable {
    static int ticket = 6; // 定义一个变量一共卖 10 张票
    // 创建 lock 对象
    static Lock l = new ReentrantLock();

    @Override
    public void run() {
        while (true) {
            method();
        }
    }

    public static void method() {
        l.lock();
        if (ticket > 0) {
            try {
                // Thread 线程对象,调用 sleep 方法,只要睡觉,就会失去 CPU 的执行权,睡醒之后
                // 继续获得 CPU 的执行权,参数是毫秒值
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                // TODO Auto-generated catch block
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + "正在买" + ticket--);
        }
        l.unlock();
    }
}

public class TicketDemoTest {
    public static void main(String[] args) {
        // 创建线程任务
        TicketDemo ticketDemo = new TicketDemo();
        // 创建三个窗口
        Thread thread1 = new Thread(ticketDemo);
        Thread thread2 = new Thread(ticketDemo);
        Thread thread3 = new Thread(ticketDemo);
        // 同时卖票
        thread1.start();
        thread2.start();
        thread3.start();
    }
}
在上述代码中,建立了数据共享机制,通过将同步锁用于代码块之上,巧妙地解决了线程安全问题,确保了数据的一致性和完整性。

相关文章