Java 多线程编程:让程序同时干多件事

本文系统讲解 Java 多线程编程,覆盖进程与线程、Java 默认多线程、Thread、Runnable、Callable、Thread 常见方法、线程安全、同步代码块、同步方法、Lock 锁、ThreadLocal、JDK 线程池和自定义 ThreadPoolExecutor。文章从概念到代码逐步展开,帮助读者理解并发任务、共享数据保护和线程资源管理。

多线程的核心目标,是让一个程序在同一段时间里处理多件事情。比如后台系统一边接收请求,一边访问数据库,一边写日志;桌面程序一边下载文件,一边保持界面可操作。学习多线程时,不要一开始就陷入复杂名词,先抓住一条主线:线程负责执行任务,锁负责保护共享数据,线程池负责管理线程资源,ThreadLocal 负责线程数据隔离

1. 进程和线程的相关概念

进程是正在运行的程序。比如打开一个浏览器、一个 IDEA、一个微信,它们在操作系统里都可以看成进程。进程有自己独立的内存空间,进程之间默认互不影响。

线程是进程内部的一条执行路径。一个进程至少有一个线程,Java 程序启动后最先运行的是 main 线程。一个进程中可以有多个线程,它们共享进程里的内存数据。

可以这样理解:

概念类比特点
进程一个公司有独立资源,进程之间隔离
线程公司里的员工共享公司资源,可以同时做不同任务

线程共享数据带来了效率,也带来了风险。多个线程同时修改同一个变量时,如果没有控制顺序,就可能出现数据错乱,这就是后面要讲的线程安全问题。

2. Java 程序默认是多线程的

很多同学以为只有自己创建了 Thread,程序才是多线程。其实不是。Java 程序启动后,JVM 本身就会启动多个线程,例如:

  • main 线程:执行我们写的 main 方法。
  • GC 线程:负责垃圾回收。
  • 编译优化相关线程:帮助 JVM 优化热点代码。
  • 信号处理、监控等 JVM 内部线程。

可以用下面的代码观察当前 JVM 中的线程:

Java
import java.util.Map;

public class ThreadListDemo {
    public static void main(String[] args) {
        // getAllStackTraces 可以拿到当前 JVM 中所有存活线程的信息
        Map<Thread, StackTraceElement[]> threadMap = Thread.getAllStackTraces();

        for (Thread thread : threadMap.keySet()) {
            System.out.println(thread.getName());
        }
    }
}

输出里通常不只有 main,还会看到一些 JVM 内部线程。这说明 Java 程序天然运行在多线程环境里。

3. 创建线程方式一:继承 Thread 类

第一种方式是自定义类继承 Thread,然后重写 run() 方法。

Java
public class MyThread extends Thread {
    /**
     * 定义线程要执行的任务。
     */
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(getName() + " 正在执行:" + i);
        }
    }
}

启动线程:

Java
public class ThreadDemo {
    public static void main(String[] args) {
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();

        t1.setName("线程A");
        t2.setName("线程B");

        // start 会开启新线程,并由 JVM 自动调用 run 方法
        t1.start();
        t2.start();
    }
}

注意:启动线程必须调用 start(),不要直接调用 run()

Java
t1.start(); // 正确:开启一条新的执行路径
t1.run();   // 错误理解:这只是普通方法调用,不会创建新线程

继承 Thread 的优点是写法直观,缺点是 Java 只支持单继承。如果这个类已经继承了其他父类,就不能再继承 Thread

4. 创建线程方式二:实现 Runnable 接口

第二种方式是实现 Runnable 接口,把“任务”和“线程对象”分开。

Java
public class MyRunnable implements Runnable {
    /**
     * 定义线程任务,Runnable 本身不代表线程,只代表可执行任务。
     */
    @Override
    public void run() {
        for (int i = 1; i <= 5; i++) {
            System.out.println(Thread.currentThread().getName() + " 正在执行:" + i);
        }
    }
}

启动线程:

Java
public class RunnableDemo {
    public static void main(String[] args) {
        MyRunnable task = new MyRunnable();

        Thread t1 = new Thread(task, "线程A");
        Thread t2 = new Thread(task, "线程B");

        t1.start();
        t2.start();
    }
}

这种方式更常用,因为它有几个优势:

  • 避免 Java 单继承限制。
  • 任务和线程分离,结构更清晰。
  • 多个线程可以共享同一个任务对象,适合模拟共享数据场景。

也可以用 Lambda 简化:

Java
Thread thread = new Thread(() -> {
    System.out.println(Thread.currentThread().getName() + " 执行任务");
}, "工作线程");

thread.start();

5. 创建线程方式三:实现 Callable 接口

Runnablerun() 方法没有返回值,也不能直接抛出受检异常。如果希望线程执行完后返回结果,可以使用 Callable

Callable 通常要配合 FutureTask 使用:

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

public class CallableDemo {
    public static void main(String[] args) throws Exception {
        Callable<Integer> task = new Callable<Integer>() {
            /**
             * 计算 1 到 100 的和,并把结果返回给调用方。
             */
            @Override
            public Integer call() {
                int sum = 0;
                for (int i = 1; i <= 100; i++) {
                    sum += i;
                }
                return sum;
            }
        };

        FutureTask<Integer> futureTask = new FutureTask<>(task);
        Thread thread = new Thread(futureTask, "计算线程");
        thread.start();

        // get 会阻塞等待,直到线程执行完成并返回结果
        Integer result = futureTask.get();
        System.out.println("计算结果:" + result);
    }
}

三种创建方式对比:

方式是否有返回值是否适合共享任务常见使用场景
继承 Thread一般简单演示、入门理解
实现 Runnable适合常规多线程任务
实现 Callable适合需要拿到线程执行结果

6. Thread 类的常见方法一

Thread 类提供了很多方法,用来设置线程名称、获取当前线程、启动线程和控制线程执行。

方法作用
start()启动线程,让 JVM 调用 run()
run()线程任务内容,直接调用不会开启新线程
setName(String name)设置线程名称
getName()获取线程名称
currentThread()获取当前正在执行的线程对象

示例:

Java
public class ThreadMethodDemo {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            Thread current = Thread.currentThread();
            System.out.println("当前线程对象:" + current);
            System.out.println("当前线程名称:" + current.getName());
        });

        thread.setName("下载线程");
        thread.start();

        System.out.println("main 方法由 " + Thread.currentThread().getName() + " 执行");
    }
}

线程名称很重要。真实项目里如果日志中只有 Thread-1Thread-2,排查问题会很痛苦。给线程起清晰名字,是一个好习惯。

7. Thread 类的常见方法二

下面这些方法会影响线程的执行节奏。

方法作用
sleep(long millis)让当前线程休眠指定毫秒
join()等待另一个线程执行完
yield()提示当前线程愿意让出 CPU
setDaemon(boolean on)设置为守护线程
setPriority(int priority)设置线程优先级

sleep 示例:

Java
public class SleepDemo {
    public static void main(String[] args) throws InterruptedException {
        for (int i = 3; i >= 1; i--) {
            System.out.println(i);

            // 让当前 main 线程休眠 1 秒
            Thread.sleep(1000);
        }

        System.out.println("开始执行");
    }
}

join 示例:

Java
public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            for (int i = 1; i <= 3; i++) {
                System.out.println("子线程执行:" + i);
            }
        });

        worker.start();

        // main 线程会等待 worker 执行结束后再继续往下走
        worker.join();

        System.out.println("子线程结束后,main 线程继续执行");
    }
}

守护线程示例:

Java
public class DaemonDemo {
    public static void main(String[] args) {
        Thread daemon = new Thread(() -> {
            while (true) {
                System.out.println("后台守护线程运行中");
            }
        });

        // 守护线程会随着所有非守护线程结束而结束
        daemon.setDaemon(true);
        daemon.start();

        System.out.println("main 线程结束");
    }
}

线程优先级只是给操作系统调度的建议,不代表优先级高的线程一定先执行。

8. 线程安全问题和同步代码块

线程安全问题通常发生在三个条件同时出现时:

  1. 有多个线程。
  2. 多个线程共享同一份数据。
  3. 多个线程同时修改这份数据。

经典例子是卖票:

Java
public class TicketTask implements Runnable {
    private int tickets = 100;

    /**
     * 多个线程共享同一个 TicketTask 对象,因此共享 tickets 变量。
     */
    @Override
    public void run() {
        while (true) {
            if (tickets > 0) {
                System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
                tickets--;
            } else {
                break;
            }
        }
    }
}

如果多个线程同时执行 if (tickets > 0)tickets--,可能出现重复卖票、卖出 0 号票等问题。原因是判断和修改不是一个不可拆分的整体。

解决方式是给操作共享数据的代码加同步代码块:

Java
public class SafeTicketTask implements Runnable {
    private int tickets = 100;
    private final Object lock = new Object();

    /**
     * 使用同步代码块保护共享变量 tickets。
     */
    @Override
    public void run() {
        while (true) {
            synchronized (lock) {
                if (tickets > 0) {
                    System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
                    tickets--;
                } else {
                    break;
                }
            }
        }
    }
}

同步代码块的格式:

Java
synchronized (锁对象) {
    // 操作共享数据的代码
}

锁对象必须是多个线程共同使用的同一个对象。如果每个线程拿到的锁对象都不同,就锁不住共享数据。

9. 同步方法

如果一个方法内部整体都需要同步,可以把方法声明为 synchronized 方法。

Java
public class SyncMethodTicketTask implements Runnable {
    private int tickets = 100;

    /**
     * 线程任务:循环调用同步方法卖票。
     */
    @Override
    public void run() {
        while (sellTicket()) {
            // 循环卖票,直到 sellTicket 返回 false
        }
    }

    /**
     * 同步方法会自动加锁,普通成员方法默认锁对象是 this。
     *
     * @return 还有票并成功卖出时返回 true,没有票时返回 false。
     */
    private synchronized boolean sellTicket() {
        if (tickets > 0) {
            System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
            tickets--;
            return true;
        }
        return false;
    }
}

同步方法的锁对象规则:

方法类型默认锁对象
普通同步方法this
静态同步方法当前类的 Class 对象

同步代码块更灵活,可以只锁关键代码;同步方法更简洁,适合整个方法都属于临界区的场景。

10. Lock 锁

synchronized 是 Java 内置锁,使用简单,但控制能力有限。Lock 是 JDK 提供的显式锁,常用实现类是 ReentrantLock

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

public class LockTicketTask implements Runnable {
    private int tickets = 100;
    private final Lock lock = new ReentrantLock();

    /**
     * 使用 Lock 手动加锁和释放锁。
     */
    @Override
    public void run() {
        while (true) {
            lock.lock();
            try {
                if (tickets > 0) {
                    System.out.println(Thread.currentThread().getName() + " 卖出第 " + tickets + " 张票");
                    tickets--;
                } else {
                    break;
                }
            } finally {
                // 释放锁必须写在 finally 中,避免异常导致锁无法释放
                lock.unlock();
            }
        }
    }
}

使用 Lock 时要牢记:

  • lock.lock()lock.unlock() 必须成对出现。
  • unlock() 一定要放在 finally 中。
  • Lock 支持更灵活的加锁能力,比如可中断锁、公平锁、尝试加锁等。

初学阶段可以这样选择:

场景推荐方式
简单同步synchronized
整个方法都需要同步同步方法
需要更灵活控制锁Lock

11. 线程池介绍和 JDK 提供的线程池

如果每来一个任务就创建一个新线程,会有几个问题:

  • 创建和销毁线程都有成本。
  • 线程数量不受控制,可能把服务器资源耗尽。
  • 缺少统一管理,不方便监控和关闭。

线程池就是用来管理线程的工具。它会提前准备一批线程,任务来了就交给空闲线程执行,执行完后线程不销毁,而是回到池里继续等待新任务。

JDK 提供了 Executors 工具类,可以快速创建线程池:

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ExecutorsDemo {
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(3);

        for (int i = 1; i <= 5; i++) {
            int taskId = i;
            pool.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
            });
        }

        // shutdown 表示不再接收新任务,但会把已提交的任务执行完
        pool.shutdown();
    }
}

常见线程池:

方法特点
newFixedThreadPool(n)固定线程数量
newSingleThreadExecutor()只有一个工作线程
newCachedThreadPool()线程数量可动态增长
newScheduledThreadPool(n)支持延迟和周期任务

不过在企业开发中,一般不推荐直接使用 Executors 创建线程池。原因是它的某些实现可能使用无界队列或允许创建过多线程,任务量过大时容易造成内存溢出或资源耗尽。

12. 自定义线程池

更推荐使用 ThreadPoolExecutor 自定义线程池参数。

Java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CustomThreadPoolDemo {
    public static void main(String[] args) {
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2,                              // 核心线程数:长期保留的线程数量
                4,                              // 最大线程数:任务高峰时最多能创建的线程数量
                60,                             // 非核心线程空闲后的存活时间
                TimeUnit.SECONDS,               // 存活时间单位
                new ArrayBlockingQueue<>(10),   // 任务队列:等待执行的任务存放位置
                new ThreadPoolExecutor.AbortPolicy() // 拒绝策略:任务太多时直接抛异常
        );

        for (int i = 1; i <= 20; i++) {
            int taskId = i;
            pool.execute(() -> {
                System.out.println(Thread.currentThread().getName() + " 执行任务 " + taskId);
            });
        }

        pool.shutdown();
    }
}

ThreadPoolExecutor 的核心参数:

参数说明
corePoolSize核心线程数
maximumPoolSize最大线程数
keepAliveTime非核心线程空闲存活时间
unit时间单位
workQueue任务队列
threadFactory线程创建工厂
handler拒绝策略

线程池执行任务的大致流程:

  1. 当前工作线程数小于核心线程数:直接创建核心线程执行任务。
  2. 核心线程已满:任务进入队列等待。
  3. 队列也满了,且线程数小于最大线程数:创建非核心线程执行任务。
  4. 线程数达到最大值,队列也满:触发拒绝策略。

常见拒绝策略:

拒绝策略行为
AbortPolicy直接抛异常,默认策略
CallerRunsPolicy让提交任务的线程自己执行
DiscardPolicy直接丢弃任务,不抛异常
DiscardOldestPolicy丢弃队列中最早的任务,再尝试提交新任务

13. ThreadLocal 线程本地变量

ThreadLocal 是 Java 提供的一种实现线程数据隔离的机制。它并不是一个 Thread,而是 Thread 的局部变量。

13.1 什么是 ThreadLocal

ThreadLocal 为每个线程提供一份单独的存储空间。当多个线程访问同一个 ThreadLocal 变量时,每个线程都会在自己的本地内存中保存一份该变量的副本,具有线程隔离的效果,不同的线程之间不会相互干扰。

13.2 ThreadLocal 常用方法

方法作用
public void set(T value)设置当前线程的线程局部变量的值
public T get()返回当前线程所对应的线程局部变量的值
public void remove()移除当前线程的线程局部变量

13.3 应用场景与示例

应用场景:

  • 解决线程安全问题:当某个变量不是线程安全的,但你又不希望使用锁(Lock/Synchronized)来同步时,可以给每个线程分配一个变量副本。
  • 跨层传递数据:在复杂的业务链条中,避免在每个方法参数里传递同一个变量(如用户信息、事务 ID 等)。

代码示例:

Java
public class ThreadLocalDemo {
    // 定义一个 ThreadLocal 变量,用来存储字符串
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        new Thread(() -> {
            threadLocal.set("线程A的数据");
            System.out.println(Thread.currentThread().getName() + " 获取数据:" + threadLocal.get());
            // 使用完记得移除,防止内存泄漏
            threadLocal.remove();
        }, "Thread-A").start();

        new Thread(() -> {
            threadLocal.set("线程B的数据");
            System.out.println(Thread.currentThread().getName() + " 获取数据:" + threadLocal.get());
            threadLocal.remove();
        }, "Thread-B").start();
    }
}

注意:在使用完 ThreadLocal 变量后,强烈建议调用 remove() 方法。因为在线程池环境下,线程是被复用的,如果不清理,可能会导致上一个任务的数据“污染”下一个任务,甚至引起内存泄漏。

多线程章节最后要记住四句话:

  • 创建线程是为了并发执行任务。
  • 共享数据必须考虑线程安全。
  • ThreadLocal 用于实现线程间的数据隔离。
  • 真实项目中更常用线程池,而不是频繁手动创建线程。