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

Java多线程编程基础

2023-07-192.7k 阅读

1. 线程基础概念

在深入探讨Java多线程编程之前,我们先来明确一些基础概念。线程(Thread)是程序执行流的最小单元,它是进程中的一个实体,一个进程可以包含多个线程。与进程不同,线程之间共享进程的资源,比如内存空间、文件描述符等,这使得线程间通信相对容易,但同时也带来了一些问题,如资源竞争。

1.1 线程与进程的区别

进程是资源分配的最小单位,它有自己独立的地址空间、内存、数据栈以及其他记录其运行状态的辅助数据。而线程是程序执行的最小单位,线程共享所属进程的资源。例如,在一个Java程序中,整个程序就是一个进程,而程序中不同的执行路径(线程)可以共享这个进程的堆内存等资源。

1.2 为什么使用多线程

使用多线程编程主要有以下几个优点:

  • 提高程序响应性:在图形用户界面(GUI)应用中,如果所有操作都在一个线程中执行,那么当进行一些耗时操作(如文件读取、网络请求)时,界面会出现卡顿,用户无法进行其他操作。通过将这些耗时操作放在单独的线程中执行,主线程(通常负责界面更新)就可以继续响应用户的操作,提高了用户体验。
  • 充分利用多核处理器:现代计算机大多配备多核处理器,多线程编程可以让不同的线程分别运行在不同的核心上,从而充分利用硬件资源,提高程序的执行效率。
  • 改善程序结构:将不同的功能模块放在不同的线程中执行,可以使程序的结构更加清晰,易于维护和扩展。

2. Java中的线程创建

在Java中,创建线程主要有两种方式:继承Thread类和实现Runnable接口。

2.1 继承Thread类

继承Thread类是创建线程的一种方式。我们需要创建一个类继承自Thread类,并重写其run方法,run方法中包含了线程要执行的代码。

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

public class ThreadCreationBySubclass {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start();
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " is running: " + i);
        }
    }
}

在上述代码中,MyThread类继承自Thread类,并重写了run方法。在main方法中,我们创建了MyThread的实例,并调用start方法启动线程。start方法会在一个新的线程中执行run方法的代码,而main方法本身所在的线程会继续执行后续的代码,这就实现了多线程并发执行。

2.2 实现Runnable接口

实现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 ThreadCreationByRunnable {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
        for (int i = 0; i < 5; i++) {
            System.out.println(Thread.currentThread().getName() + " is running: " + i);
        }
    }
}

在这段代码中,MyRunnable类实现了Runnable接口,并重写了run方法。然后在main方法中,我们创建了MyRunnable的实例,并将其作为参数传递给Thread的构造函数来创建线程对象,最后调用start方法启动线程。

2.3 两种方式的比较

  • 继承Thread:优点是代码简单直观,直接重写run方法即可。缺点是由于Java的单继承特性,如果一个类已经继承了其他类,就无法再继承Thread类。
  • 实现Runnable接口:优点是更加灵活,一个类可以实现多个接口,并且符合面向对象的设计原则。缺点是代码相对复杂一些,需要创建Thread对象并将Runnable实例作为参数传递进去。

3. 线程的生命周期

线程在其生命周期中有多种状态,了解这些状态对于理解多线程编程至关重要。

3.1 新建(New)

当我们使用new关键字创建一个线程对象时,线程处于新建状态。例如,Thread thread = new Thread(new MyRunnable());,此时线程还没有开始执行,只是一个对象而已。

3.2 就绪(Runnable)

当调用线程的start方法后,线程进入就绪状态。处于就绪状态的线程已经具备了运行的条件,等待CPU调度执行。但此时它并不一定立即执行,只是在可运行线程池中等待获得CPU资源。

3.3 运行(Running)

当CPU调度到处于就绪状态的线程时,该线程进入运行状态,开始执行run方法中的代码。

3.4 阻塞(Blocked)

线程在运行过程中,可能会因为某些原因进入阻塞状态,暂时停止执行。常见的导致线程阻塞的原因有:

  • 等待锁:当线程尝试获取一个被其他线程持有的锁时,如果锁不可用,线程会进入阻塞状态,等待锁的释放。
  • 调用sleep方法:通过调用Thread.sleep(long millis)方法,线程会进入阻塞状态,暂停执行指定的毫秒数。例如:
public class ThreadSleepExample {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            try {
                System.out.println("Thread is going to sleep");
                Thread.sleep(2000);
                System.out.println("Thread woke up");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
        thread.start();
    }
}
  • 调用wait方法:在一个同步块中,当线程调用对象的wait方法时,线程会释放它持有的锁,并进入阻塞状态,直到其他线程调用该对象的notifynotifyAll方法唤醒它。

3.5 死亡(Dead)

当线程的run方法执行完毕,或者因为异常导致run方法提前退出,线程就进入死亡状态,此时线程不再具备运行的能力。

4. 线程同步

由于多线程共享进程的资源,当多个线程同时访问和修改共享资源时,就可能会出现数据不一致等问题,这就需要线程同步机制来解决。

4.1 同步方法

在Java中,可以通过synchronized关键字修饰方法来实现线程同步。当一个线程调用同步方法时,它会自动获取该方法所属对象的锁。只有获取到锁的线程才能执行该方法,其他线程需要等待锁的释放。

class Counter {
    private int count = 0;

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

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

public class SynchronizedMethodExample {
    public static void main(String[] args) {
        Counter counter = new Counter();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                counter.increment();
            }
        });
        thread1.start();
        thread2.start();
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Final count: " + counter.getCount());
    }
}

在上述代码中,Counter类的incrementgetCount方法都被synchronized修饰,这样在多线程环境下,就可以保证count变量的操作是线程安全的。

4.2 同步块

除了同步方法,还可以使用同步块来实现线程同步。同步块的语法为synchronized(对象) { // 同步代码块 },只有获取到指定对象锁的线程才能执行同步块中的代码。

class Data {
    private int value;

    public void update(int newValue) {
        Object lock = this;
        synchronized (lock) {
            value = newValue;
        }
    }

    public int getValue() {
        Object lock = this;
        synchronized (lock) {
            return value;
        }
    }
}

在这个例子中,updategetValue方法通过同步块来保证对value变量的操作是线程安全的。

4.3 锁的原理

Java中的锁是基于对象的,每个对象都有一个锁标志。当一个线程进入同步方法或同步块时,它会尝试获取对象的锁。如果锁可用,线程获取锁并执行同步代码;如果锁不可用,线程会进入阻塞状态,直到锁被释放。锁的获取和释放是由Java虚拟机(JVM)自动管理的。

5. 线程间通信

在多线程编程中,线程之间经常需要相互协作,这就涉及到线程间通信。

5.1 wait、notify和notifyAll方法

waitnotifynotifyAll方法是Java提供的线程间通信的基本机制,它们都定义在Object类中,所以每个对象都可以调用这些方法。

  • wait方法:调用该方法的线程会释放它持有的对象锁,并进入等待状态,直到其他线程调用该对象的notifynotifyAll方法唤醒它。
  • notify方法:随机唤醒一个在该对象上等待的线程。
  • notifyAll方法:唤醒所有在该对象上等待的线程。
class ProducerConsumer {
    private int value;
    private boolean available = false;

    public synchronized void produce(int value) {
        while (available) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        this.value = value;
        System.out.println("Produced: " + value);
        available = true;
        notify();
    }

    public synchronized int consume() {
        while (!available) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        available = false;
        System.out.println("Consumed: " + value);
        notify();
        return value;
    }
}

public class ThreadCommunicationExample {
    public static void main(String[] args) {
        ProducerConsumer pc = new ProducerConsumer();
        Thread producer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                pc.produce(i);
            }
        });
        Thread consumer = new Thread(() -> {
            for (int i = 1; i <= 5; i++) {
                pc.consume();
            }
        });
        producer.start();
        consumer.start();
    }
}

在上述代码中,ProducerConsumer类通过waitnotify方法实现了生产者 - 消费者模式,生产者线程生产数据,消费者线程消费数据,并且通过available标志来控制生产和消费的节奏。

5.2 使用BlockingQueue

java.util.concurrent.BlockingQueue是Java并发包中提供的一个线程安全的队列,它提供了阻塞操作的方法,如puttake。当队列满时,put方法会阻塞,直到队列有空间;当队列空时,take方法会阻塞,直到队列有元素。

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 = 1; i <= 5; i++) {
            try {
                queue.put(i);
                System.out.println("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 value = queue.take();
                System.out.println("Consumed: " + value);
            } catch (InterruptedException e) {
                e.printStackTrace();
                break;
            }
        }
    }
}

public class BlockingQueueExample {
    public static void main(String[] args) {
        BlockingQueue<Integer> queue = new LinkedBlockingQueue<>(3);
        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();
        }
    }
}

在这个例子中,BlockingQueue简化了生产者 - 消费者模式的实现,使得代码更加简洁和易于理解。

6. 线程池

在多线程编程中,如果频繁地创建和销毁线程,会带来很大的性能开销。线程池可以有效地解决这个问题。

6.1 线程池的原理

线程池维护着一组线程,这些线程可以被重复使用来执行任务。当有任务提交到线程池时,线程池会从线程队列中取出一个空闲线程来执行任务。如果线程队列中没有空闲线程,且线程池中的线程数量没有达到最大线程数,线程池会创建新的线程来执行任务。如果线程池中的线程数量已经达到最大线程数,任务会被放入任务队列中等待执行。

6.2 使用ThreadPoolExecutor创建线程池

java.util.concurrent.ThreadPoolExecutor是Java提供的一个灵活的线程池实现类。

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

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

在上述代码中,我们创建了一个ThreadPoolExecutor,它的核心线程数为2,最大线程数为4,线程存活时间为10秒,任务队列容量为10。当提交的任务数量超过任务队列容量且线程池中的线程数量达到最大线程数时,后续任务会根据线程池的拒绝策略进行处理。

6.3 线程池的拒绝策略

当线程池无法处理新提交的任务时(任务队列已满且线程数达到最大线程数),会根据拒绝策略来处理任务。常见的拒绝策略有:

  • AbortPolicy:默认策略,直接抛出RejectedExecutionException异常。
  • CallerRunsPolicy:将任务交给调用者所在的线程执行。
  • DiscardPolicy:直接丢弃任务,不做任何处理。
  • DiscardOldestPolicy:丢弃任务队列中最老的任务,然后尝试提交新任务。

7. 并发工具类

Java并发包(java.util.concurrent)提供了许多实用的并发工具类,帮助我们更方便地进行多线程编程。

7.1 CountDownLatch

CountDownLatch允许一个或多个线程等待其他一组线程完成操作。它通过一个计数器来实现,当计数器的值为0时,等待的线程会被释放。

import java.util.concurrent.CountDownLatch;

public class CountDownLatchExample {
    public static void main(String[] args) {
        int numThreads = 5;
        CountDownLatch latch = new CountDownLatch(numThreads);
        for (int i = 0; i < numThreads; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " is doing some work");
                    Thread.sleep(2000);
                    System.out.println(Thread.currentThread().getName() + " has finished work");
                    latch.countDown();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
        try {
            latch.await();
            System.out.println("All threads have finished, main thread can continue");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在这个例子中,主线程调用latch.await()方法等待其他5个线程完成任务,每个线程完成任务后调用latch.countDown()方法将计数器减1,当计数器为0时,主线程被唤醒。

7.2 CyclicBarrier

CyclicBarrier允许一组线程相互等待,直到所有线程都到达某个屏障点。与CountDownLatch不同的是,CyclicBarrier的计数器可以重置,可以被重复使用。

import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierExample {
    public static void main(String[] args) {
        int numThreads = 3;
        CyclicBarrier barrier = new CyclicBarrier(numThreads, () -> {
            System.out.println("All threads have reached the barrier");
        });
        for (int i = 0; i < numThreads; i++) {
            new Thread(() -> {
                try {
                    System.out.println(Thread.currentThread().getName() + " is doing some work");
                    Thread.sleep((long) (Math.random() * 3000));
                    System.out.println(Thread.currentThread().getName() + " is waiting at the barrier");
                    barrier.await();
                    System.out.println(Thread.currentThread().getName() + " continues after the barrier");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在上述代码中,3个线程会在barrier.await()处等待,直到所有线程都调用了await方法,此时会执行CyclicBarrier构造函数中传入的Runnable任务,然后所有线程继续执行后续代码。

7.3 Semaphore

Semaphore是一个计数信号量,它控制同时访问某个资源的线程数量。它通过一个计数器来表示可用的许可数量,线程获取许可时计数器减1,释放许可时计数器加1。

import java.util.concurrent.Semaphore;

public class SemaphoreExample {
    public static void main(String[] args) {
        int permits = 2;
        Semaphore semaphore = new Semaphore(permits);
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                try {
                    semaphore.acquire();
                    System.out.println(Thread.currentThread().getName() + " has acquired a permit");
                    Thread.sleep(2000);
                    System.out.println(Thread.currentThread().getName() + " is releasing a permit");
                    semaphore.release();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

在这个例子中,Semaphore的许可数量为2,所以最多只有2个线程可以同时获取许可并执行任务,其他线程需要等待许可的释放。

8. 多线程编程的常见问题与调试

在多线程编程中,会遇到一些常见的问题,并且需要有效的调试手段来解决这些问题。

8.1 死锁

死锁是多线程编程中最常见的问题之一,当两个或多个线程相互等待对方释放锁时,就会发生死锁。例如:

class DeadlockExample {
    private static final Object lock1 = new Object();
    private static final Object lock2 = new Object();

    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1 has acquired lock1");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("Thread 1 has acquired lock2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2 has acquired lock2");
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("Thread 2 has acquired lock1");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

在上述代码中,thread1获取了lock1,然后尝试获取lock2,而thread2获取了lock2,然后尝试获取lock1,这样就形成了死锁。

8.2 活锁

活锁类似于死锁,不同的是,处于活锁的线程会不断尝试改变状态来解决问题,但却始终无法取得进展。例如,两个线程都在尝试释放自己的资源并获取对方的资源,它们不断地重复这个过程,但永远无法成功。

8.3 饥饿

当某些线程因为优先级较低,始终无法获取到CPU资源,从而无法执行时,就会发生饥饿现象。在Java中,可以通过设置线程的优先级来尽量避免饥饿,但需要注意的是,线程优先级只是一个建议,JVM并不一定严格按照优先级来调度线程。

8.4 调试多线程程序

调试多线程程序比调试单线程程序要困难得多,因为线程的执行顺序是不确定的。常见的调试方法有:

  • 使用日志:在关键代码处添加日志输出,记录线程的执行状态和变量的值,通过分析日志来找出问题。
  • 使用断点调试:在IDE中设置断点,单步执行代码,观察线程的执行过程和变量的变化。但需要注意的是,断点可能会影响线程的执行顺序,所以需要多次调试来确认问题。
  • 使用工具:如Java VisualVM等工具,可以监控线程的状态、CPU使用率、内存使用等信息,帮助我们找出多线程程序中的问题。

通过深入理解Java多线程编程的基础概念、创建方式、生命周期、同步机制、线程间通信、线程池以及并发工具类等知识,并掌握常见问题的解决和调试方法,我们就能够编写出高效、稳定的多线程程序,充分发挥多核处理器的性能优势,提升程序的响应性和扩展性。