MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java多线程编程入门

2024-01-262.4k 阅读

一、多线程基础概念

1.1 进程与线程

在深入探讨 Java 多线程编程之前,我们首先要理解进程(Process)和线程(Thread)这两个关键概念。

进程:进程是程序的一次执行过程,是系统进行资源分配和调度的基本单位。每个进程都有独立的内存空间,包含代码、数据和进程控制块(PCB)等。例如,当我们启动一个浏览器程序,它就是一个进程,浏览器进程会占用一定的系统资源,如内存、CPU 时间等。不同的进程之间相互隔离,数据不能直接共享。

线程:线程是进程中的一个执行路径,是程序执行的最小单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、打开的文件等。例如,在浏览器进程中,可能有一个线程负责加载网页内容,另一个线程负责处理用户的交互操作,还有一个线程负责检查是否有新的消息通知等。线程之间的切换开销比进程小得多,因为它们共享资源,不需要重新分配内存等操作。

1.2 多线程的优势

  • 提高 CPU 利用率:在单线程程序中,当某个任务需要等待 I/O 操作完成(如从硬盘读取文件、网络请求等)时,CPU 处于空闲状态,造成资源浪费。而多线程程序可以在一个线程等待 I/O 时,让其他线程继续执行,充分利用 CPU 资源。
  • 增强程序的响应性:对于一些图形界面(GUI)程序,使用多线程可以使界面在后台任务执行时仍然保持响应,不会出现卡顿现象。比如,在一个文件下载的 GUI 程序中,使用一个线程负责下载文件,另一个线程负责处理用户对界面的操作,如暂停、取消下载等。
  • 便于模型化:现实生活中的很多场景可以很自然地用多线程来模拟。例如,一个银行系统中,多个用户同时进行取款、存款操作,每个用户的操作可以看作是一个线程。

二、Java 中的线程创建

2.1 继承 Thread 类

在 Java 中,创建线程最直接的方式之一就是继承 Thread 类。下面是一个简单的示例代码:

class MyThread extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName() + " is running: " + i);
        }
    }
}

public class ThreadExample1 {
    public static void main(String[] args) {
        MyThread thread1 = new MyThread();
        MyThread thread2 = new MyThread();
        thread1.start();
        thread2.start();
    }
}

在上述代码中,我们定义了一个 MyThread 类,它继承自 Thread 类。然后重写了 run 方法,run 方法中的代码就是线程要执行的任务。在 main 方法中,我们创建了两个 MyThread 实例,并调用 start 方法启动线程。注意,不能直接调用 run 方法,否则它将作为普通方法执行,而不会开启新的线程。

2.2 实现 Runnable 接口

另一种创建线程的方式是实现 Runnable 接口。相比继承 Thread 类,实现 Runnable 接口更具灵活性,因为 Java 不支持多重继承,而一个类可以实现多个接口。示例代码如下:

class MyRunnable implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " is running: " + i);
        }
    }
}

public class ThreadExample2 {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

这里我们定义了 MyRunnable 类实现了 Runnable 接口,并实现了 run 方法。在 main 方法中,我们创建了 MyRunnable 的实例,并将其作为参数传递给 Thread 构造函数来创建线程对象,最后启动线程。

2.3 实现 Callable 接口(Java 5+)

Callable 接口与 Runnable 接口类似,但 Callable 接口的 call 方法可以有返回值,并且可以抛出异常。这在一些需要获取线程执行结果的场景中非常有用。示例代码如下:

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.FutureTask;

class MyCallable implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 0; i < 100; i++) {
            sum += i;
        }
        return sum;
    }
}

public class ThreadExample3 {
    public static void main(String[] args) {
        MyCallable callable = new MyCallable();
        FutureTask<Integer> futureTask = new FutureTask<>(callable);
        Thread thread = new Thread(futureTask);
        thread.start();
        try {
            Integer result = futureTask.get();
            System.out.println("The result is: " + result);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,MyCallable 类实现了 Callable 接口,call 方法计算 0 到 99 的和并返回。我们通过 FutureTask 来包装 Callable 对象,FutureTask 既实现了 Runnable 接口,又提供了获取 Callable 执行结果的方法。在 main 方法中,我们创建线程并启动,然后通过 futureTask.get() 获取线程执行的结果。

三、线程的生命周期

3.1 新建(New)

当我们使用 new 关键字创建一个线程对象时,线程就处于新建状态。例如:

Thread thread = new Thread(new MyRunnable());

此时线程对象已经被分配了内存,初始化了成员变量,但尚未启动。

3.2 就绪(Runnable)

当调用线程的 start 方法后,线程进入就绪状态。处于就绪状态的线程具备了运行的条件,但还没有被分配到 CPU 资源,需要等待 CPU 调度。在就绪状态下,线程会在就绪队列中等待,一旦获得 CPU 时间片,就会进入运行状态。

3.3 运行(Running)

当线程获得 CPU 时间片开始执行 run 方法中的代码时,线程处于运行状态。在运行过程中,线程会执行任务,可能会因为各种原因让出 CPU 资源,比如调用 yield 方法主动让出 CPU,或者时间片用完等。

3.4 阻塞(Blocked)

线程在运行过程中,可能会遇到一些情况导致暂时无法继续执行,从而进入阻塞状态。常见的导致线程阻塞的情况有:

  • I/O 阻塞:当线程执行 I/O 操作(如读取文件、网络通信等)时,由于需要等待数据的传输,线程会进入阻塞状态,直到 I/O 操作完成。
  • 同步阻塞:当线程试图获取一个被其他线程占用的锁时,会进入阻塞状态,直到获取到锁。
  • 等待阻塞:当线程调用 wait 方法时,会进入等待阻塞状态,直到其他线程调用 notifynotifyAll 方法唤醒它。

3.5 死亡(Dead)

当线程的 run 方法执行完毕,或者线程抛出一个未捕获的异常导致 run 方法提前结束,线程就进入死亡状态。一旦线程进入死亡状态,就不能再重新启动。

四、线程同步

4.1 线程安全问题

在多线程环境下,如果多个线程同时访问和修改共享资源,可能会导致数据不一致等线程安全问题。例如,下面的代码演示了一个简单的线程安全问题:

class Counter {
    private int count = 0;

    public void increment() {
        count++;
    }

    public int getCount() {
        return count;
    }
}

class MyThread4 implements Runnable {
    private Counter counter;

    public MyThread4(Counter counter) {
        this.counter = counter;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            counter.increment();
        }
    }
}

public class ThreadSafetyExample {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread thread1 = new Thread(new MyThread4(counter));
        Thread thread2 = new Thread(new MyThread4(counter));
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("Expected count: 2000, Actual count: " + counter.getCount());
    }
}

在上述代码中,Counter 类有一个 increment 方法用于对 count 变量进行自增操作。两个线程同时调用 increment 方法,理论上最终 count 的值应该是 2000,但由于线程执行的不确定性,实际结果往往小于 2000。这是因为 count++ 操作不是原子性的,它包含读取、增加和写入三个步骤,在多线程环境下可能会出现数据竞争。

4.2 synchronized 关键字

为了解决线程安全问题,Java 提供了 synchronized 关键字。它可以用于修饰方法或代码块,保证同一时间只有一个线程能够访问被 synchronized 修饰的部分。

修饰实例方法

class CounterSync {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

在上述代码中,incrementgetCount 方法都被 synchronized 修饰,这意味着当一个线程调用其中一个方法时,其他线程无法同时调用这两个方法中的任何一个,从而保证了数据的一致性。

修饰静态方法

class StaticCounterSync {
    private static int count = 0;

    public static synchronized void increment() {
        count++;
    }

    public static synchronized int getCount() {
        return count;
    }
}

静态方法被 synchronized 修饰时,锁是针对类的,而不是针对实例。这意味着所有实例调用静态同步方法时,都会竞争同一个锁。

修饰代码块

class BlockCounterSync {
    private int count = 0;
    private final Object lock = new Object();

    public void increment() {
        synchronized (lock) {
            count++;
        }
    }

    public int getCount() {
        synchronized (lock) {
            return count;
        }
    }
}

这里通过 synchronized (lock) 修饰代码块,锁对象是 lock。只有获取到 lock 锁的线程才能执行同步代码块中的内容。

4.3 Lock 接口(Java 5+)

除了 synchronized 关键字,Java 5 引入了 Lock 接口及其实现类,如 ReentrantLock,提供了更灵活的线程同步控制。示例代码如下:

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class LockCounter {
    private int count = 0;
    private Lock lock = new ReentrantLock();

    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }

    public int getCount() {
        lock.lock();
        try {
            return count;
        } finally {
            lock.unlock();
        }
    }
}

在上述代码中,ReentrantLock 实现了 Lock 接口。通过 lock 方法获取锁,在操作完成后通过 unlock 方法释放锁,并且使用 try - finally 块确保即使在操作过程中抛出异常,锁也能被正确释放。与 synchronized 相比,Lock 接口提供了更多的功能,如可中断的锁获取、公平锁等。

五、线程通信

5.1 wait()、notify() 和 notifyAll() 方法

在多线程编程中,线程之间经常需要进行通信,以协调它们的工作。wait()notify()notifyAll() 方法是实现线程通信的重要手段。这些方法必须在 synchronized 块中调用。

示例代码

class Message {
    private String content;
    private boolean ready = false;

    public synchronized void write(String msg) {
        while (ready) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        content = msg;
        ready = true;
        notifyAll();
    }

    public synchronized String read() {
        while (!ready) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        ready = false;
        notifyAll();
        return content;
    }
}

class Writer implements Runnable {
    private Message message;

    public Writer(Message message) {
        this.message = message;
    }

    @Override
    public void run() {
        String[] messages = {"Hello", "World", "Java"};
        for (String msg : messages) {
            message.write(msg);
            System.out.println("Writer wrote: " + msg);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        message.write("END");
    }
}

class Reader implements Runnable {
    private Message message;

    public Reader(Message message) {
        this.message = message;
    }

    @Override
    public void run() {
        String msg;
        do {
            msg = message.read();
            System.out.println("Reader read: " + msg);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        } while (!"END".equals(msg));
    }
}

public class ThreadCommunicationExample {
    public static void main(String[] args) {
        Message message = new Message();
        new Thread(new Writer(message)).start();
        new Thread(new Reader(message)).start();
    }
}

在上述代码中,Message 类有一个 content 变量用于存储消息,ready 变量用于表示消息是否准备好。Writer 线程通过 write 方法写入消息,Reader 线程通过 read 方法读取消息。当 readytrue 时,Writer 线程调用 wait 方法等待,直到 Reader 线程读取消息后调用 notifyAll 方法唤醒它;当 readyfalse 时,Reader 线程调用 wait 方法等待,直到 Writer 线程写入消息后调用 notifyAll 方法唤醒它。

5.2 Condition 接口(Java 5+)

Condition 接口是 Lock 接口的一个辅助接口,提供了更灵活的线程通信方式。与 wait()notify() 基于对象监视器不同,Condition 是基于 Lock 的。示例代码如下:

import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

class ConditionMessage {
    private String content;
    private boolean ready = false;
    private Lock lock = new ReentrantLock();
    private Condition condition = lock.newCondition();

    public void write(String msg) {
        lock.lock();
        try {
            while (ready) {
                condition.await();
            }
            content = msg;
            ready = true;
            condition.signalAll();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    public String read() {
        lock.lock();
        try {
            while (!ready) {
                condition.await();
            }
            ready = false;
            condition.signalAll();
            return content;
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
        return null;
    }
}

在上述代码中,ConditionMessage 类使用 LockCondition 实现了与前面 Message 类类似的功能。Conditionawait 方法类似于 wait 方法,signalAll 方法类似于 notifyAll 方法,但它们是基于 Lock 进行操作的,提供了更细粒度的控制。

六、线程池

6.1 为什么使用线程池

在多线程编程中,如果频繁地创建和销毁线程,会带来较大的开销,影响程序的性能。线程池可以解决这个问题,它预先创建一定数量的线程,并将这些线程复用。当有任务提交时,线程池中的线程会执行任务,任务完成后,线程不会被销毁,而是返回线程池等待下一个任务。这样可以减少线程创建和销毁的开销,提高系统的性能和稳定性。

6.2 Java 中的线程池实现

Java 提供了 Executor 框架来实现线程池,其中核心的类和接口有 ExecutorExecutorServiceThreadPoolExecutor 等。

使用 Executors 工厂类创建线程池

  • 固定大小线程池
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class FixedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(3);
        for (int i = 0; i < 5; i++) {
            executorService.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executorService.shutdown();
    }
}

在上述代码中,Executors.newFixedThreadPool(3) 创建了一个固定大小为 3 的线程池。这意味着线程池中始终保持 3 个线程,当有任务提交时,如果线程池中有空闲线程,就会分配给该线程执行任务;如果没有空闲线程,任务会在队列中等待,直到有线程空闲。

  • 缓存线程池
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CachedThreadPoolExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newCachedThreadPool();
        for (int i = 0; i < 5; i++) {
            executorService.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executorService.shutdown();
    }
}

Executors.newCachedThreadPool() 创建的缓存线程池会根据任务的需求动态创建和销毁线程。如果线程池中有空闲线程,就会复用这些线程;如果没有空闲线程,就会创建新的线程来执行任务。当线程空闲一定时间(默认为 60 秒)后,会被销毁。

  • 单线程线程池
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class SingleThreadExecutorExample {
    public static void main(String[] args) {
        ExecutorService executorService = Executors.newSingleThreadExecutor();
        for (int i = 0; i < 5; i++) {
            executorService.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executorService.shutdown();
    }
}

Executors.newSingleThreadExecutor() 创建的单线程线程池只有一个线程,所有任务会按照提交的顺序依次执行,保证了任务执行的顺序性。

使用 ThreadPoolExecutor 自定义线程池

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CustomThreadPoolExample {
    public static void main(String[] args) {
        BlockingQueue<Runnable> taskQueue = new LinkedBlockingQueue<>(5);
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                4,
                10,
                TimeUnit.SECONDS,
                taskQueue
        );
        for (int i = 0; i < 10; i++) {
            executor.submit(() -> {
                System.out.println(Thread.currentThread().getName() + " is running");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }
        executor.shutdown();
    }
}

在上述代码中,ThreadPoolExecutor 的构造函数接受多个参数:

  • corePoolSize:核心线程数,线程池会始终保持这些线程存活,即使它们处于空闲状态。
  • maximumPoolSize:最大线程数,线程池允许创建的最大线程数。
  • keepAliveTime:当线程数大于核心线程数时,多余的空闲线程的存活时间。
  • unitkeepAliveTime 的时间单位。
  • workQueue:任务队列,用于存放等待执行的任务。

通过自定义线程池,可以根据实际需求灵活调整线程池的参数,以达到最优的性能。

七、常见的多线程设计模式

7.1 生产者 - 消费者模式

生产者 - 消费者模式是一种经典的多线程设计模式,它用于协调生产者和消费者之间的工作。生产者负责生成数据,消费者负责处理数据。在多线程环境下,通过队列来缓存数据,生产者将数据放入队列,消费者从队列中取出数据进行处理。

示例代码

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

class Producer implements Runnable {
    private BlockingQueue<Integer> queue;

    public Producer(BlockingQueue<Integer> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            try {
                queue.put(i);
                System.out.println("Producer produced: " + i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

class Consumer implements Runnable {
    private BlockingQueue<Integer> queue;

    public Consumer(BlockingQueue<Integer> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true) {
            try {
                Integer num = queue.take();
                System.out.println("Consumer consumed: " + num);
                if (num == 9) {
                    break;
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class ProducerConsumerExample {
    public static void main(String[] args) {
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(5);
        Thread producerThread = new Thread(new Producer(queue));
        Thread consumerThread = new Thread(new Consumer(queue));
        producerThread.start();
        consumerThread.start();
        try {
            producerThread.join();
            consumerThread.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,Producer 类实现了 Runnable 接口,在 run 方法中不断生成数据并放入 BlockingQueue 中。Consumer 类同样实现了 Runnable 接口,从 BlockingQueue 中取出数据并处理。BlockingQueue 起到了缓存数据的作用,并且提供了线程安全的操作方法,如 puttake,当队列满时 put 方法会阻塞,当队列空时 take 方法会阻塞,从而实现了生产者和消费者之间的协调。

7.2 单例模式与多线程

单例模式是一种常用的设计模式,它确保一个类只有一个实例,并提供一个全局访问点。在多线程环境下,实现单例模式需要考虑线程安全问题。

饿汉式单例

class EagerSingleton {
    private static final EagerSingleton instance = new EagerSingleton();

    private EagerSingleton() {}

    public static EagerSingleton getInstance() {
        return instance;
    }
}

饿汉式单例在类加载时就创建了实例,由于类加载机制是线程安全的,所以饿汉式单例天生就是线程安全的。

懒汉式单例(线程不安全)

class LazySingletonUnsafe {
    private static LazySingletonUnsafe instance;

    private LazySingletonUnsafe() {}

    public static LazySingletonUnsafe getInstance() {
        if (instance == null) {
            instance = new LazySingletonUnsafe();
        }
        return instance;
    }
}

懒汉式单例在第一次调用 getInstance 方法时才创建实例。但在多线程环境下,如果多个线程同时调用 getInstance 方法,并且都判断 instancenull,就会创建多个实例,导致线程安全问题。

懒汉式单例(线程安全 - synchronized 方法)

class LazySingletonSafe {
    private static LazySingletonSafe instance;

    private LazySingletonSafe() {}

    public static synchronized LazySingletonSafe getInstance() {
        if (instance == null) {
            instance = new LazySingletonSafe();
        }
        return instance;
    }
}

通过在 getInstance 方法上添加 synchronized 关键字,可以保证同一时间只有一个线程能够进入该方法,从而解决了线程安全问题。但这种方式会导致性能问题,因为每次调用 getInstance 方法都需要获取锁。

懒汉式单例(线程安全 - 双重检查锁定)

class LazySingletonDCL {
    private static volatile LazySingletonDCL instance;

    private LazySingletonDCL() {}

    public static LazySingletonDCL getInstance() {
        if (instance == null) {
            synchronized (LazySingletonDCL.class) {
                if (instance == null) {
                    instance = new LazySingletonDCL();
                }
            }
        }
        return instance;
    }
}

双重检查锁定通过两次 if (instance == null) 判断,在第一次判断时,如果 instance 不为 null,就直接返回,避免了每次都获取锁的开销。在第二次判断时,由于有 synchronized 块保证线程安全,所以可以确保只创建一个实例。这里使用 volatile 关键字修饰 instance 变量,是为了防止指令重排序,保证在多线程环境下的正确性。

通过深入理解这些多线程编程的基础知识、技术和设计模式,开发者可以编写出高效、稳定且线程安全的 Java 程序,充分发挥多线程编程的优势,提升系统的性能和响应性。在实际应用中,还需要根据具体的业务需求和场景,合理选择和优化多线程的实现方式。