深入浅出Java多线程(五):线程间通信

引言


大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第五篇内容:线程间通信。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!

在现代编程实践中,多线程技术是提高程序并发性能、优化系统资源利用率的关键手段。Java作为主流的多线程支持语言,不仅提供了丰富的API来创建和管理线程,更重要的是它内置了强大的线程间通信机制,使得多个线程能够有效地协作并同步执行任务,从而确保数据的一致性和系统的稳定性。

在实际开发中,尤其是服务器端应用中,多线程并行处理可以极大地提升服务响应速度和吞吐量。然而,多线程环境中的共享资源访问往往会带来复杂性,比如竞争条件、死锁等问题。为了解决这些问题,我们必须熟练掌握Java中用于控制线程同步与通信的各种方法和技术。

引言部分首先引入一个生活化比喻:想象一下,多线程就像是许多工人在同一工作台上协同作业,为了保证工作的有序进行和资源的安全使用,我们需要一种类似于“信号灯”或“调度员”的机制来协调这些工人之间的交互。在Java中,这种协调机制就体现在对象锁(即互斥锁)上,就如同只有一个工具箱可供同时操作一样,一个对象锁同一时间只能被一个线程持有。通过synchronized关键字对代码块或方法进行标注,我们能够确保在任意时刻只有一个线程访问特定的临界区资源。

例如,考虑两个学生线程A和B在抄写同一份暑假作业答案的情景。为了防止他们因老师中途修改答案而造成两人作业内容不一致的问题,我们可以通过给整个抄写过程加上对象锁,确保先让老师完成修改再让学生们开始抄写,或者学生们抄完后再由老师去修改,这就体现了线程间的同步执行。

public class ObjectLock {
    private static final Object lock = new Object();

    static class StudentThread implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                // 这里模拟抄写作业的过程
                for (int i = 0; i < 100; i++) {
                    System.out.println("Student is copying answer " + i);
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread studentA = new Thread(new StudentThread());
        Thread teacher = new Thread(() -> {
            // 模拟老师修改答案前后的等待和通知逻辑
            synchronized (lock) {
                // 修改答案...
                lock.notifyAll();  // 告诉所有等待的学生现在可以继续抄写了
            }
        });

        studentA.start();
        // 假设老师需要修改答案
        try { Thread.sleep(1000); } catch (InterruptedException e) {}
        teacher.start();
    }
}

上述示例展示了如何利用对象锁实现简单的线程同步,确保了学生线程在老师修改答案后才开始抄写。当然,更复杂的场景下,线程间通信还包括诸如等待/通知机制、管道流、join方法以及ThreadLocal等多样化的技术手段。这些方法各有特色且应用场景各异,深入理解它们的工作原理并灵活运用,将有助于开发者构建高效、安全的多线程应用程序。后续章节我们将逐一探讨这些机制,并通过实例代码揭示其内在逻辑和应用场景。

锁与同步


在Java多线程编程中,锁和同步机制是确保多个线程正确访问共享资源、避免并发问题的核心手段。首先,我们来深入理解这两个概念。

概念解释

锁(Locking) 是基于对象的,每个Java对象都可以关联一个内在的锁,也被称为“对象锁”。当一个线程试图访问某个需要同步的代码块时,它必须先获取到相关的对象锁。如果该锁已经被其他线程持有,那么当前线程就必须等待,直到锁被释放。这种一对一的关系就如同婚姻中的排他性:一次只能有一个线程“结婚”(即持有锁),而其他想要进入这段关系的线程则必须等到“离婚”(即释放锁)才能获得机会。

同步(Synchronization) 则是为了保证线程间的执行顺序和数据一致性。它通过synchronized关键字实现,使得同一时间只有一个线程可以执行特定的代码块或方法。同步确保了在临界区内的操作不会被多个线程同时执行,从而有效防止了数据竞争和不一致的情况发生。比如,两个学生线程A和B在抄写同一份暑假作业答案时,同步机制会确保老师修改完答案后,所有学生都看到的是最新版本的答案,而不是旧版。

代码示例

下面是一个使用对象锁进行线程同步的简单示例。在这个例子中,我们希望线程A完成其任务后再启动线程B,以确保它们按序执行。

public class ObjectLockExample {
    private static final Object lock = new Object();

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 100; i++) {
                    System.out.println("Thread A is working on task " + i);
                }
                // 线程A完成工作后,唤醒可能在等待的线程B
                lock.notify();
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                try {
                    // 线程B先等待线程A完成工作
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 100; i++) {
                    System.out.println("Thread B is now working on task " + i);
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new ThreadA());
        threadA.start();

        // 主线程等待片刻确保线程A已经获得锁并开始执行
        Thread.sleep(10);

        Thread threadB = new Thread(new ThreadB());
        threadB.start();
    }
}

锁机制解析

在上述代码中,synchronized关键字修饰的代码块表示对对象锁的加锁和解锁过程。线程A首先获得锁并执行循环打印,执行完成后调用notify()通知等待的线程。线程B在运行时,同样尝试获取相同的锁,但由于线程A尚未释放,因此线程B将被阻塞在synchronized代码块外,直至线程A调用notify()并退出同步块,释放锁。此时,线程B得以获取锁,并从等待状态转为就绪状态继续执行。

总结起来,锁与同步机制在Java多线程环境中起到至关重要的作用,它们约束了不同线程对共享资源的访问秩序,确保了线程间的数据一致性以及程序的正确性。通过对锁的合理运用,开发者可以有效地避免竞态条件和死锁等并发问题的发生。

等待/通知机制


基本原理

在Java多线程编程中,基于对象的等待/通知机制是一种高级同步手段,它允许一个或多个线程在特定条件满足前进入等待状态,而在其他线程完成某个操作后通过发送通知唤醒这些等待中的线程。这一机制主要依赖于java.lang.Object类提供的wait()notify()notifyAll()方法实现。

  • wait(): 当前线程调用该方法时,会释放当前持有的对象锁,并进入无限期等待状态,直到被其他线程调用同一个对象的notify()notifyAll()方法唤醒。
  • notify(): 随机唤醒一个正在等待该对象监视器(即锁)的线程。
  • notifyAll(): 唤醒所有正在等待该对象监视器的线程。

使用等待/通知机制时,必须确保在synchronized修饰的方法或代码块内调用这些方法,因为只有持有对象锁的线程才能执行它们。此外,调用wait()方法后,线程需要重新获得锁才能继续执行。

实例演示

以下是一个使用等待/通知机制控制线程交替打印数字的例子:

public class WaitAndNotifyExample {
    private static final Object lock = new Object();

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    System.out.println("ThreadA: " + i);
                    lock.notify(); // 唤醒可能等待的线程B
                    try {
                        if (i != 4) { // 不是最后一个数则进入等待
                            lock.wait(); // 线程A等待被唤醒
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                lock.notify(); // 最后一次通知,以防万一还有等待的线程
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    try {
                        lock.wait(); // 线程B先等待,让线程A开始
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("ThreadB: " + i);
                    lock.notify(); // 唤醒线程A进行下一轮输出
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new ThreadA());
        threadA.start();
        Thread.sleep(100); // 给线程A一些时间初始化
        Thread threadB = new Thread(new ThreadB());
        threadB.start();
    }
}

运行上述代码,将会看到线程A和线程B交替打印从0到4的整数序列。在这个例子中,线程A首先获取锁并打印第一个数字,然后调用notify()唤醒线程B;线程B在获得锁后立即调用wait()让自己进入等待状态,此时线程A再次获取锁并打印下一个数字,循环此过程直至完成五次打印。整个过程中,两个线程通过共享的对象锁与等待/通知机制实现了精确的协作和通信。

管道通信


定义与应用

在Java多线程编程中,管道(Pipes)是一种特殊的通信机制,它允许线程之间通过内存流进行数据传输。JDK提供的java.io.PipedWriterjava.io.PipedReader用于字符流之间的通信,而java.io.PipedOutputStreamjava.io.PipedInputStream则是基于字节流的通信工具。管道通信模型类似于现实生活中的水管,一个线程作为生产者将信息写入管道的一端,另一个线程作为消费者从管道的另一端读取这些信息。

管道通信特别适用于需要在线程间高效传递数据的场景,例如,一个线程负责生成数据并将其发送到另一个线程进一步处理或展示。这种机制尤其适用于避免使用共享变量带来的同步问题,以及简化线程间的协调工作。

代码实践

以下是一个利用Java管道进行线程间通信的实例代码:

public class PipeExample {
    static class ReaderThread implements Runnable {
        private PipedReader reader;

        public ReaderThread(PipedReader reader) {
            this.reader = reader;
        }

        @Override
        public void run() {
            System.out.println("Reader thread is ready to read");
            try {
                int receive;
                while ((receive = reader.read()) != -1) {
                    System.out.print((char) receive);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    static class WriterThread implements Runnable {
        private PipedWriter writer;

        public WriterThread(PipedWriter writer) {
            this.writer = writer;
        }

        @Override
        public void run() {
            System.out.println("Writer thread is ready to write");
            try {
                writer.write("Hello, World from the pipe!");
                writer.flush(); // 确保数据被完全写入管道
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    writer.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws IOException {
        PipedWriter writer = new PipedWriter();
        PipedReader reader = new PipedReader();

        // 注意:必须先连接管道两端,否则会抛出异常
        reader.connect(writer);

        Thread readerThread = new Thread(new ReaderThread(reader));
        Thread writerThread = new Thread(new WriterThread(writer));

        readerThread.start();
        writerThread.start();

        // 等待两个线程执行完毕
        readerThread.join();
        writerThread.join();
    }
}

运行上述代码,输出结果将是“Hello, World from the pipe!”。在这个示例中,我们创建了一个字符管道,并启动了两个线程,一个负责向管道中写入字符串,另一个则负责从管道中读取并打印出来。由于管道通信是单向的,因此确保了数据只能按照指定方向流动,从而实现线程间的有序通信。

其他通信方式


join方法

join() 方法是Java中 Thread 类的一个关键实例方法,用于同步线程执行。当一个线程调用另一个线程的 join() 方法时,当前线程将进入等待状态,直到被调用 join() 的线程完成其任务并结束。这在需要确保主线程等待子线程执行完毕后再继续执行的情况下尤为有用。

例如,假设主线程创建了一个耗时计算的任务交给子线程执行,并且主线程希望在子线程完成计算后获取结果:

public class JoinExample {
    static class LongRunningTask implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println("我是子线程,开始执行耗时计算...");
                Thread.sleep(2000); // 模拟耗时操作
                int result = performComputation(); // 执行计算
                System.out.println("我是子线程,计算完成,结果为: " + result);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        private int performComputation() {
            return 42// 示例计算结果
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread longRunning = new Thread(new LongRunningTask());
        longRunning.start();

        // 主线程等待子线程完成
        longRunning.join();

        // 子线程结束后,主线程可以安全地访问子线程的结果(此处假设已通过共享变量或其他机制传递)
        System.out.println("主线程:子线程已完成,我可以继续执行后续操作了");
    }
}

sleep方法

sleep()Thread 类提供的一个静态方法,它使当前线程暂停指定的时间量。与 wait() 方法不同的是,sleep() 不会释放任何锁资源,即线程在睡眠期间依然持有其已经获得的锁。此外,sleep() 方法不会抛出 InterruptedException 异常,除非在调用 sleep() 的过程中,该线程被中断。

示例代码:

public class SleepExample {
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread A is running: " + i);
                try {
                    Thread.sleep(1000); // 线程A每运行一次循环就休眠1秒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        threadA.start();
    }
}

ThreadLocal类

ThreadLocal 类提供了一种特殊的线程绑定存储机制,每个线程都有自己的独立副本。这意味着,即使多个线程同时引用同一个 ThreadLocal 实例,它们各自存取和修改的值也互不影响。

以下是一个使用 ThreadLocal 的简单示例,展示如何在一个多线程环境下为每个线程维护独立的上下文信息:

public class ThreadLocalDemo {
    public static class WorkerThread extends Thread {
        private final ThreadLocal<String> context;

        public WorkerThread(ThreadLocal<String> context, String name) {
            this.context = context;
            setName(name);
        }

        @Override
        public void run() {
            context.set(Thread.currentThread().getName() + ": Initial Value");

            // 假设进行了一些处理
            String newValue = "Processed by " + getName();
            context.set(newValue);

            System.out.println(getName() + " has its own value: " + context.get());
        }
    }

    public static void main(String[] args) {
        ThreadLocal<String> context = new ThreadLocal<>();
        WorkerThread worker1 = new WorkerThread(context, "Thread-1");
        WorkerThread worker2 = new WorkerThread(context, "Thread-2");

        worker1.start();
        worker2.start();
    }
}

在这个例子中,WorkerThread 继承自 Thread 并使用 ThreadLocal 来保存线程特定的上下文信息。即使两个线程都使用了相同的 ThreadLocal 实例,它们各自的 context 变量仍然保持隔离,每个线程都可以安全地读取和更新自己的私有数据。

信号量机制


volatile关键字

在Java多线程编程中,volatile关键字用于确保变量的可见性和有序性。声明为volatile的变量会保证当一个线程修改了该变量值时,其他所有线程都能立即看到这个修改的结果。例如,在下面的示例中,我们用volatile关键字实现了一个简单的“信号量”模型来控制线程A和线程B交替打印数字:

public class SignalExample {
    private static volatile int signal = 0;

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            while (signal < 5) {
                if (signal % 2 == 0) {
                    System.out.println("Thread A: " + signal);
                    synchronized (SignalExample.class{
                        signal++;
                    }
                }
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            while (signal < 5) {
                if (signal % 2 == 1) {
                    System.out.println("Thread B: " + signal);
                    synchronized (SignalExample.class{
                        signal = signal + 1;
                    }
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(new ThreadA()).start();
        Thread.sleep(100); // 确保线程A有机会先执行
        new Thread(new ThreadB()).start();
    }
}

尽管此处volatile关键字确保了对signal变量修改的可见性,但由于signal++不是原子操作,因此仍需要使用synchronized同步块以确保更新操作的原子性。

Semaphore类

JDK提供的Semaphore类是一个更完整的信号量实现,它可以用来控制同时访问特定资源的线程数量,从而有效解决线程间的并发控制问题。以下是一个使用Semaphore模拟停车场车位管理的例子:

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    private final Semaphore parkingSpaces = new Semaphore(3); // 假设有3个停车位

    static class Car implements Runnable {
        private final Semaphore semaphore;

        public Car(Semaphore semaphore) {
            this.semaphore = semaphore;
        }

        @Override
        public void run() {
            try {
                semaphore.acquire(); // 请求获取一个车位
                System.out.println(Thread.currentThread().getName() + "已停车");
                Thread.sleep(1000); // 模拟停车时间
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                semaphore.release(); // 释放车位
                System.out.println(Thread.currentThread().getName() + "已离开");
            }
        }
    }

    public static void main(String[] args) {
        SemaphoreDemo demo = new SemaphoreDemo();

        for (int i = 0; i < 6; i++) { // 创建6辆车
            Thread car = new Thread(new Car(demo.parkingSpaces), "Car-" + (i + 1));
            car.start();
        }
    }
}

在这个例子中,Semaphore对象parkingSpaces初始化为3,表示有3个可用停车位。每辆汽车(线程)尝试获取一个车位前都要调用acquire()方法,成功获取后才能进行停车动作;完成停车后通过调用release()方法释放车位,以便其他车辆可以继续停车。这样便实现了基于信号量的多线程资源调度与同步。

总结


在Java多线程编程中,线程间的通信是实现协同工作和同步操作的关键环节。本文通过一系列实例和详细说明,探讨了多种有效的线程间通信方式。

  1. 锁与同步 锁机制是Java中最基础的同步手段,利用synchronized关键字或显式Lock类确保同一时间只有一个线程访问共享资源。代码示例展示了如何使用对象锁来确保线程A执行完毕后线程B再开始执行,从而达到线程间的有序执行。
  2. 等待/通知机制wait()notify() 方法提供了一种灵活的线程通信方式,允许线程在满足特定条件时进入等待状态,并在条件改变时被其他线程唤醒。通过实例演示了线程A和线程B如何交替打印数字,展示了等待/通知机制在线程协作中的应用。
  3. 管道通信 Java提供的PipedInputStream和PipedOutputStream(或PipedReader和PipedWriter)实现了线程之间的数据流传递,特别适用于简单的信息交换场景。案例中两个线程通过管道完成字符流的读写操作,展现了管道通信的直观效果。
  4. join方法Thread.join() 方法允许一个线程等待另一个线程终止后再继续执行,保证了线程间的执行顺序。示例代码展示了主线程等待子线程计算完成后才继续执行的操作。
  5. sleep方法Thread.sleep() 使当前线程暂停指定的时间,但它并不释放锁,主要用于简单地延时线程执行。虽然在本讨论中未给出具体示例,但其作用在于控制线程执行节奏。
  6. ThreadLocal类 ThreadLocal提供了线程局部变量功能,每个线程拥有独立的副本,解决了线程间共享变量的隔离问题。实例表明即使多个线程引用同一个ThreadLocal实例,它们各自存储和获取的数据互不影响。
  7. 信号量机制 volatile关键字确保了变量在不同线程间的可见性,而Semaphore类则是一个更高级的信号量工具,用于管理并发访问资源的线程数量。示例代码模拟了一个停车场车位管理场景,通过Semaphore实现对有限资源的访问控制。

未来随着Java技术的发展和多核CPU普及,多线程通信的重要性日益凸显。了解并掌握以上介绍的各种线程间通信方法,有助于开发者设计出更为高效、稳定且易于维护的并发程序。同时,后续章节将继续深入讲解volatile关键字的内存语义、信号量Semaphore在复杂场景下的运用以及更多基于JDK的线程通信工具类如CountDownLatch、CyclicBarrier等,进一步丰富和完善多线程编程的知识体系。

本文使用 markdown.com.cn 排版

posted @ 2024-02-01 15:07  解码猿  阅读(617)  评论(0编辑  收藏  举报