JavaEE 初阶 - 多线程(初阶)

1、认识线程(Thread)

1.1 概念

1.什么是线程

我们之前编写的 Java 程序,大多数都是这样的:

java 复制代码
public class ThreadDemo {

    public static void main(String[] args) {

        System.out.println("程序开始");

        for (int i = 0; i < 5; i++) {
            System.out.println(i);
        }

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

程序执行的时候,代码基本上是按照一定的顺序执行。

这种执行方式,我们可以简单理解为一个线程从头到尾负责执行一条执行流。

那么什么是线程?

简单来说,线程(Thread)是操作系统进行调度和执行的基本单位之一。

在 Java 程序中,我们可以创建多个线程,让多个任务同时处于执行状态。

例如:

text 复制代码
线程1:下载文件
线程2:播放音乐
线程3:处理用户请求
线程4:刷新界面

这些任务就可以由不同的线程来执行。

所以,多线程,就是在一个进程中同时存在多个线程,让多个线程共同完成任务。

2.为什么需要线程

假设我们正在开发一个聊天软件。

用户打开软件以后,需要同时完成:

text 复制代码
接收消息
发送消息
播放提示音
刷新聊天界面
上传文件

如果所有事情都由一个线程完成:

text 复制代码
接收消息 -> 发送消息 -> 播放声音 -> 刷新界面 -> 上传文件

那么某一个任务如果执行时间比较长,就可能影响其他任务。

例如:

text 复制代码
上传文件 -> 需要 10 秒 -> 其他任务只能等待

用户就可能感觉软件卡住了。

如果使用多个线程:

text 复制代码
线程1 -> 接收消息
线程2 -> 发送消息
线程3 -> 播放声音
线程4 -> 上传文件

多个任务就可以交错执行。

这就是线程最直接的价值,把一个程序中的不同任务拆分成多个执行流。

3.进程和线程的区别

学习线程之前,必须先理解进程。

当我们启动一个 Java 程序时,操作系统会创建一个进程。

例如:

text 复制代码
1.启动 Java 程序

2.操作系统创建进程

3.JVM 在进程中运行

一个进程里面又可以包含多个线程,所以可以简单理解为进程是资源分配的基本单位之一,线程是进程内部的执行流。

例如:

微信进程中,有接收消息线程、发送消息线程、网络线程、界面线程,不同进程之间通常拥有相互独立的内存空间。

而同一个进程中的线程,共享进程中的大部分资源。

这既是多线程的优势,也是后面出现线程安全问题的重要原因。

4.Java 线程和操作系统线程的关系

我们在 Java 中写:

java 复制代码
Thread t = new Thread();

这里创建的是 Java 层面的 Thread 对象。

当我们t.start();之后,Java 会请求运行环境创建并调度相应的线程执行任务。

现代 Java 还提供了虚拟线程(Virtual Thread)。Java 21 起,虚拟线程成为正式特性;虚拟线程由 JVM 调度,可以大量创建,并不等同于传统的平台线程。

5.并发和并行的简单理解

这两个概念非常容易混淆。

并发

并发:多个任务在一段时间内交替推进。

例如只有一个 CPU 核心:

text 复制代码
时间 →
线程1:████    ████
线程2:    ████    ████

CPU 在不同线程之间进行切换。

从宏观上看,线程1和线程2同时推进,这就是并发。

并行

并行:多个任务在同一时刻真正同时执行。

例如有多个 CPU 核心:

text 复制代码
CPU核心1 → 线程1 █████████
CPU核心2 → 线程2 █████████
CPU核心3 → 线程3 █████████

多个任务真正同时运行。

可以简单记一下:

text 复制代码
并发:一个时间段内一起推进
并行:同一时刻真正一起执行

多线程程序既可以体现并发,也可以在多核 CPU 上实现真正的并行。

1.2 第一个多线程程序

1.创建第一个线程

先来看最简单的线程创建方式。

java 复制代码
public class ThreadDemo {

    public static void main(String[] args) {

        Thread thread = new Thread() {
            @Override
            public void run() {
                while (true) {
                    System.out.println("新线程正在运行");
                }
            }
        };

        thread.start();
    }
}

这里thread.start();非常重要。

调用 start() 之后,JVM 会调度这个线程开始执行它的 run() 方法。Oracle 官方教程也把"定义 run() 中的任务"和调用 start() 启动线程作为创建线程的基本模式。

2.观察多个线程同时执行

我们可以让主线程和新线程同时打印。

java 复制代码
public class ThreadDemo {

    public static void main(String[] args) {

        Thread thread = new Thread() {
            @Override
            public void run() {
                for (int i = 0; i < 5000; i++) {
                    System.out.println("新线程:" + i);
                }
            }
        };

        thread.start();

        for (int i = 0; i < 5000; i++) {
            System.out.println("主线程:" + i);
        }
    }
}

运行结果是随机的,多个线程的执行顺序通常不是固定的。

3.理解多线程执行的不确定性

刚学的时候看到这个:

java 复制代码
thread.start();
System.out.println("main");

会下意识认为,一定先执行 thread,再执行 main

这是不对的。

start() 的作用是:让线程进入可调度执行的状态。

而不是现在立刻执行这个线程,并且执行完以后再执行下面的代码。

具体谁先执行、执行多久、什么时候切换,是由 JVM 和底层操作系统调度机制共同决定的。

因此,多线程程序的执行顺序通常具有不确定性。

1.3 创建线程

1.继承 Thread 类

java 复制代码
class MyThread extends Thread {

    @Override
    public void run() {
        System.out.println("线程执行");
    }
    
}

public class ThreadDemo {

    public static void main(String[] args) {

        MyThread thread = new MyThread();

        thread.start();
    }
    
}

这里的关键是class MyThread extends Thread,并重写了run(),最后start()启动线程。

Oracle 官方教程同样把"继承 Thread 并重写 run()"列为创建线程的基本方式之一。

2.实现 Runnable 接口

java 复制代码
class MyRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("线程执行");
    }
    
}

public class ThreadDemo {

    public static void main(String[] args) {

        Runnable runnable = new MyRunnable();

        Thread thread = new Thread(runnable);

        thread.start();
    }
}

Runnable -> 表示一个任务

Thread -> 负责执行这个任务

这种方式将任务和线程进行了分离。

这也是官方教程比较推荐理解的方式,因为任务对象不需要继承 Thread,更加灵活。

3.使用匿名内部类

如果只使用一次,就没有必要专门定义一个类。

可以:

java 复制代码
Thread thread = new Thread(new Runnable() {

    @Override
    public void run() {
        System.out.println("线程执行");
    }
});

thread.start();

这样代码更加简洁。

4.使用 Lambda 表达式

因为 Runnable 是一个函数式接口,所以可以使用 Lambda。

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("线程执行");
});

thread.start();

甚至可以进一步写成:

java 复制代码
new Thread(() -> System.out.println("线程执行")).start();

这种写法在现代 Java 代码中非常常见。

5.其他创建线程的方式简单介绍

Java 还提供了更高级的并发工具。

例如:

text 复制代码
Callable
Future
Executor
ExecutorService
ThreadPoolExecutor

后面我们会学习线程池。

1.4 多线程的优势------增加运行速度

1.单线程执行

假设现在有两个任务:

text 复制代码
任务A:执行1000次计算
任务B:执行1000次计算

单线程:

text 复制代码
线程1
 ↓
执行任务A
 ↓
执行任务B

假设:

text 复制代码
任务A:5秒
任务B:5秒

总时间大约为10秒。

2.多线程执行

如果两个任务彼此独立:

text 复制代码
线程1 → 任务A
线程2 → 任务B

在多核 CPU 的情况下,两个任务可能真正并行执行。

理论上:

text 复制代码
任务A:5秒
任务B:5秒

总时间大约为5秒

所以多线程可能提高程序效率。

3.多线程为什么可能提高程序效率

主要原因是可以让多个独立任务同时推进。

尤其是CPU 密集型任务、IO 密集型任务。

在合适的场景下,多线程可以充分利用 CPU 或者在等待 IO 时让其他任务继续执行。

例如:

text 复制代码
线程1 -> 等待网络数据

线程2 -> 继续执行计算

这样 CPU 就不会因为线程1等待网络而完全闲置。

4.多线程为什么不一定更快

很多人会误以为线程越多,程序越快。

实际上完全不是。

因为线程本身也有成本:

text 复制代码
创建线程
线程调度
线程上下文切换
线程同步
线程之间竞争资源

例如:

text 复制代码
线程1
线程2
线程3
线程4
......
线程1000

如果任务本身非常小,线程管理和切换的成本可能比真正执行任务的成本还高。

而且多个线程同时修改共享数据,还会引入:

text 复制代码
线程安全
锁竞争
死锁
内存可见性

等问题。

所以多线程不是为了简单地增加线程数量,而是为了合理利用并发能力。

2、Thread 类及常见方法

2.1 Thread 的常见构造方法

1.Thread()

最简单:

java 复制代码
Thread thread = new Thread();

然后可以继承 Thread 并重写 run()。

2.Thread(Runnable target)

java 复制代码
Runnable task = () -> {
    System.out.println("执行任务");
};

Thread thread = new Thread(task);
thread.start();

Runnable -> 任务

Thread -> 执行任务

3.Thread(String name)

可以指定线程名称:

java 复制代码
Thread thread = new Thread("worker");

线程名称对于调试、日志、排查问题非常有帮助。

4.Thread(Runnable target, String name)

同时指定任务和名称:

java 复制代码
Runnable task = () -> {
    System.out.println("执行任务");
};

Thread thread = new Thread(task, "worker");

thread.start();

2.2 Thread 的几个常见属性

1.线程名称

获取:

java 复制代码
Thread.currentThread().getName();

例如:

java 复制代码
System.out.println(Thread.currentThread().getName());

主线程通常叫 main 。

自定义线程可以:

java 复制代码
Thread t = new Thread(() -> {
    System.out.println(Thread.currentThread().getName());
}, "myThread");

t.start();

输出 myThread 。

2.线程优先级

可以:

java 复制代码
线程对象.setPriority(Thread.MAX_PRIORITY);

优先级范围通常是:1 ~ 10

常量:

java 复制代码
MIN_PRIORITY = 1
NORM_PRIORITY = 5
MAX_PRIORITY = 10

但是需要注意,线程优先级不是严格的执行顺序保证。

不能因为,thread.setPriority(10);设置了之后,就认为线程一定先执行。

线程调度仍然是复杂的运行时行为。

3.守护线程

Java 中线程可以分为:

  • 用户线程 -> 也叫前台线程
  • 守护线程 -> 也叫后台线程

用户线程:程序中的核心线程,JVM 必须等待所有用户线程结束后才会退出。

守护线程:为其他线程提供服务的辅助线程,当所有用户线程结束后,守护线程会被 JVM 自动终止,JVM 随之退出。通常用于执行一些后台辅助工作。

例如:

java 复制代码
Thread thread = new Thread(() -> {

    while (true) {
        System.out.println("后台工作");
    }

});

thread.setDaemon(true);
/*
setDaemon(true) 必须在 start() 之前调用。
如果线程已经启动,再调用 setDaemon() 会抛出 IllegalThreadStateException。
*/

thread.start();

这里 thread 就是一个守护线程。

当 JVM 中只剩下守护线程时,JVM 可以结束,所以,守护线程一般不能承担必须保证完成的核心业务。

注意:

text 复制代码
只要还有一个用户线程没有结束,JVM 就不会退出。
当所有用户线程都结束后,JVM 会直接退出,不管守护线程是否还在运行。

4.线程是否存活

可以使用thread.isAlive();判断线程是否还处于存活状态。

例如:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("执行");
});

System.out.println(thread.isAlive());

thread.start();

System.out.println(thread.isAlive());

线程启动以后,直到执行结束之前,通常都处于存活状态。

2.3 启动一个线程------start()

1.start() 的作用

启动线程。

thread.start();这是线程真正进入执行流程的入口。

2.start() 和 run() 的区别

这是比较容易搞混的问题。

看下面例子:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println(Thread.currentThread().getName());
});

如果调用:

java 复制代码
thread.run();

那么它只是普通的方法调用,执行它的仍然是当前线程。

而 thread.start() 才是启动新的线程。

3.为什么不能直接调用 run() 启动线程

例如主线程main执行 thread.run();

实际上:

text 复制代码
main -> 调用 thread.run() -> run 中代码仍然由 main 线程中执行

而 thread.start() 则是:

text 复制代码
main -> start() -> 新线程被调度 -> 新线程执行 run()

因此:

text 复制代码
run() -> 普通方法调用

start() -> 启动新线程

Oracle 当前 Thread API 也明确指出,run() 是线程执行任务的方法,而 start() 用于调度线程开始独立执行;一个线程最多只能启动一次。

4.为什么不能重复调用 start()

例如:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("hello");
});

thread.start();
thread.start();

第二次调用会抛出:

text 复制代码
IllegalThreadStateException

因为一个 Thread 对象只能被启动一次。

线程结束以后,也不能通过再次 start() 把它重新启动。

2.4 中断一个线程

1.interrupt() 是什么

线程有时候会执行很长时间。

例如:

java 复制代码
Thread thread = new Thread(() -> {

    while (true) {
        System.out.println("执行中...");
    }

});

thread.start();

如果我们想让它结束,可以使用:

java 复制代码
thread.interrupt();

但是 interrupt() 不是强制杀死线程。

2.interrupt 并不是强制杀死线程

例如:

java 复制代码
Thread thread = new Thread(() -> {

    while (true) {
        System.out.println("执行中...");
    }

});

thread.start();

thread.interrupt();

如果线程自己的代码根本没有检查中断状态:

text 复制代码
interrupt() -> 线程可能继续运行

所以,interrupt() 更准确地说是向线程发出"希望你停止/处理中断"的信号。

3.中断标志

可使用 Thread.currentThread().isInterrupted() 查看当前线程的中断状态。

例如:

java 复制代码
Thread thread = new Thread(() -> {

    while (!Thread.currentThread().isInterrupted()) {

        System.out.println("执行中...");
    }

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

thread.start();

主线程执行后:

java 复制代码
thread.interrupt();

那么新线程最终就可以结束 thread 线程的循环了。

4.阻塞状态下的中断

如果线程正在 Thread.sleep(...) 等可中断阻塞操作中:

java 复制代码
try {
    Thread.sleep(10000);
} catch (InterruptedException e) {
    System.out.println("线程被中断");
}

此时另一个线程:

java 复制代码
thread.interrupt();

可能使 sleep() 抛出 InterruptedException 这个异常,从而让线程获得处理退出机会。

5.如何正确结束一个线程

比较推荐的思想:

text 复制代码
1.不要强制杀死

2.发出中断信号

3.线程自己检查

4.完成必要清理

5.正常退出

例如:

java 复制代码
Thread thread = new Thread(() -> {

    while (!Thread.currentThread().isInterrupted()) {

        System.out.println("工作中");

        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }

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

这里尤其要注意:

java 复制代码
Thread.currentThread().interrupt();

是为了在捕获 InterruptedException 后恢复中断状态,让更上层代码仍然能够观察到这个中断请求。

2.5 等待一个线程------join()

1.join() 的作用

假设:

text 复制代码
线程A执行任务

线程B需要等待线程A完成

可以使用:

java 复制代码
threadA.join();

表示当前线程等待 threadA 执行结束。

2.为什么需要等待线程

例如:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("开始计算");

    // 模拟耗时操作
});

thread.start();

System.out.println("获取计算结果");

如果主线程直接继续执行:

text 复制代码
主线程 -> 启动计算线程 -> 马上获取结果

计算线程可能还没有计算完成。

所以可以:

java 复制代码
thread.start();
thread.join();

System.out.println("获取结果");

变成:

text 复制代码
启动线程 -> 等待线程结束 -> 获取结果

3.join() 的基本使用

java 复制代码
Thread thread = new Thread(() -> {

    for (int i = 0; i < 5; i++) {
        System.out.println(i);
    }

});

thread.start();

try {
    thread.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

System.out.println("线程执行结束");

4.join() 和线程执行顺序

注意,join() 可以人为建立一种等待关系。

例如:

text 复制代码
main
 ↓
start thread
 ↓
join
 ↓
等待 thread
 ↓
thread结束
 ↓
main继续

所以,join 可以让原本不确定的执行关系变成明确的等待关系。

2.6 获取当前线程引用

1.currentThread()

使用:

java 复制代码
Thread.currentThread();

获取当前正在执行代码的线程。

例如:

java 复制代码
System.out.println(Thread.currentThread().getName());

2.什么是当前线程

例如:

java 复制代码
public static void main(String[] args) {

    System.out.println(Thread.currentThread().getName());
    
}

这里当前线程就是 main 。

如果在线程任务中:

java 复制代码
Thread thread = new Thread(() -> {

    System.out.println(
        Thread.currentThread().getName()
    );

});

thread.start();

那么当前线程就是新创建的线程。

3.创建线程与当前线程的区别

例如:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("任务线程:" +
            Thread.currentThread().getName());
});

System.out.println("当前线程:" +
        Thread.currentThread().getName());

thread.start();

thread 是一个 Thread 对象。

而 Thread.currentThread() 表示当前正在执行这段代码的线程。

这是两个不同的概念。

2.7 休眠当前线程

1.sleep()

让当前线程休眠:

java 复制代码
Thread.sleep(1000);

表示大约暂停 1000ms ,也就是1秒。

2.sleep() 的基本使用

java 复制代码
public class SleepDemo {

    public static void main(String[] args)
            throws InterruptedException {

        for (int i = 0; i < 5; i++) {

            System.out.println(i);

            Thread.sleep(1000);
        }
    }
}

运行时每次输出之间大约间隔 1 秒。

3.sleep() 对线程状态的影响

线程调用 sleep() 之后,会进入 TIMED_WAITING 状态。

休眠结束以后由 TIMED_WAITING 这个状态,变为 RUNNABLE 这个状态,然后等待调度执行。

4.sleep() 是否释放锁

这是面试中非常经典的问题:

text 复制代码
sleep 会不会释放锁?

答案:不会。

例如:

java 复制代码
synchronized (lock) {

    Thread.sleep(1000);
    
}

线程虽然睡眠了,但是它仍然持有 lock ,因此其他线程仍然无法进入需要同一把锁的同步区域。

这一点和 wait() 完全不同,后面学习 wait/notify 时会重点对比。

3、线程的状态

3.1 观察线程的所有状态

Java 的 Thread.State 定义了六种线程状态:

text 复制代码
NEW
RUNNABLE
BLOCKED
WAITING
TIMED_WAITING
TERMINATED

1.NEW

线程对象创建出来,但是还没有调用 start() 。

例如:

java 复制代码
Thread thread = new Thread(() -> {
    System.out.println("hello");
});

此时,thread的状态就是new。

2.RUNNABLE

调用 thread.start() 以后,线程进入可运行状态。

需要注意,Java 的 RUNNABLE 是一个比较宽泛的状态。

它既可以表示正在运行,也可以表示已经准备运行,等待 CPU 调度。

所以 Java 中没有单独定义 RUNNING 状态。

3.BLOCKED

表示排队等着其他事情。

比如说:线程A持有 lock ,但线程B尝试获取 lock,这时候是获取不到的,所以线程B就是 BLOCKED 这个状态。

4.WAITING

也表示排队等着其他事情,但是这个是没有超时时间的等待。

例如:

java 复制代码
thread.join();

或者:

java 复制代码
object.wait();

都可能让线程进入 WAITING 的状态。

5.TIMED_WAITING

带时间限制的等待。

例如:

java 复制代码
Thread.sleep(1000);

或者:

java 复制代码
object.wait(1000);

或者:

java 复制代码
thread.join(1000);

都可能进入 TIMED_WAITING 状态。

6.TERMINATED

线程执行完成以后,TERMINATED 表示线程已经结束。

注意:TERMINATED 以后不能再次 start() 启动线程。

3.2 线程状态和状态转移的意义

1.为什么需要了解线程状态

因为多线程程序出现问题时,我们经常需要回答:

text 复制代码
这个线程现在在干什么?
为什么没有执行?
为什么一直等待?
为什么获取不到锁?
为什么一直不结束?

这些问题最终都可以通过:线程状态 + 线程之间的关系进行分析。

2.不同状态分别代表什么

可以总结:

状态 含义
NEW 创建了线程对象,还没启动
RUNNABLE 可运行/正在运行
BLOCKED 等待获取 synchronized 锁
WAITING 无限期等待
TIMED_WAITING 限时等待
TERMINATED 执行结束

3.Java 线程状态与操作系统线程状态的区别

这里要注意,Java 的六种状态不是操作系统线程状态的简单一一映射。

Java Thread.State 是 JVM 层面对线程生命周期的抽象。

例如 RUNNABLE 在 Java 层面可以覆盖:

text 复制代码
正在运行
等待 CPU 调度

因此不能简单认为:RUNNABLE = CPU 正在执行。

4.Java 中为什么没有单独的 RUNNING 状态

因为 Java 将 Ready、Running 等执行相关状态统一抽象为 RUNNABLE ,这样 Java 层面的线程状态模型更加简单。

所以看到 RUNNABLE,不代表线程一定正在 CPU 上执行。

3.3 观察线程的状态和转移

可以通过:

java 复制代码
thread.getState();

获取线程状态。

例如:

java 复制代码
Thread thread = new Thread(() -> {

    try {
        Thread.sleep(3000);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }

});

System.out.println(thread.getState());

thread.start();

System.out.println(thread.getState());

Thread.sleep(100);

System.out.println(thread.getState());

thread.join();

System.out.println(thread.getState());

可能观察到:

text 复制代码
NEW
RUNNABLE
TIMED_WAITING
TERMINATED

具体时刻可能略有不同,因为线程调度具有不确定性。

4、多线程带来的风险------线程安全(重点)

这一章开始进入多线程真正的核心。

前面我们一直在讲多线程可以提高效率。

但是多线程也会带来新的问题。

其中最重要的问题之一就是线程安全。

4.1 观察线程不安全

先看一个经典案例。

假设有一个变量:

java 复制代码
private static int count = 0;

然后创建多个线程,每个线程执行:

java 复制代码
count++;

例如:

java 复制代码
public class ThreadDemo {

    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

我们期待的结果是20000,但实际运行结果可能小于20000。

例如18342或者19427,甚至每次运行结果都不同。

这就是线程不安全问题。

4.2 线程安全的概念

1.什么是线程安全

简单来说:多个线程同时执行某段代码时,程序仍然能够得到符合预期的结果,那么这段代码可以认为是线程安全的。

反过来:多个线程同时执行时可能产生错误结果,就是线程不安全。

2.什么是线程不安全

例如:

java 复制代码
count++;

多个线程同时执行:

text 复制代码
线程1 → count++

线程2 → count++

如果没有合理同步,就可能出现:

text 复制代码
数据覆盖
数据丢失
结果不一致

3.为什么线程安全很重要

多线程程序中,一旦线程安全出现问题:

text 复制代码
数据错误
订单重复
库存超卖
余额错误
状态异常

以上都有可能发生。

例如:

text 复制代码
商品库存 = 1

用户A购买
用户B购买

如果两个线程同时判断:

text 复制代码
库存 > 0

然后同时扣库存:

text 复制代码
1 → 0
1 → 0

最终可能出现,两个用户都买成功,但库存实际上只有一个。

这就是不安全的问题。

4.3 线程不安全的原因

1.线程调度的不确定性

多个线程执行:线程1、线程2、线程3执行顺序不是固定的。

例如:

text 复制代码
线程1:读取
线程2:读取
线程1:修改
线程2:修改

就可能产生问题。

2.多线程共享数据

例如:

java 复制代码
static int count;

多个线程都可以访问到这个 count ,这就是共享数据。

多线程安全问题通常就发生在共享数据 + 并发修改这样的场景中。

3.操作不具有原子性

重点看:

java 复制代码
count++;

很多人以为 count++ 就是一步操作。

实际上可以粗略理解成:

text 复制代码
读取 count -> 加 1 -> 写回 count

假设 count = 10 ,两个线程同时执行:

text 复制代码
线程1:读取10
线程2:读取10

线程1:10+1=11
线程2:10+1=11

线程1:写回11
线程2:写回11

最后 count = 11,而不是12

这就是典型的复合操作不是原子的。

4.内存可见性问题

多个线程共享变量时,还可能出现:线程A修改变量之后,线程B没有及时观察到最新值。

这就是可见性问题。

例如:

java 复制代码
boolean running = true;

线程A中检测这个变量:

java 复制代码
while (running) {
}

线程B修改这个变量:

java 复制代码
running = false;

理论上我们希望线程A看到 false ,然后结束。

但在没有建立适当内存可见性保证时,线程之间对共享变量的观察不能简单按照"一个线程写了,另一个线程立刻就能看到"的方式理解。

这就是后面:

java 复制代码
volatile
synchronized

要解决的问题。

5.指令重排序问题

现代编译器、JIT 和处理器为了提高性能,可能对部分操作进行重排序。

单线程情况下,只要最终效果符合程序语义,一些执行顺序可以进行调整。

但是多线程情况下:

text 复制代码
线程A
线程B

之间的观察关系可能受到影响。

因此 Java 内存模型需要提供:可见性、有序性方面的保证。

现在暂时不需要深入 CPU 缓存一致性协议、指令级流水线等底层细节。

先记住:多线程程序不仅有"操作顺序"的问题,还有"什么时候看到其他线程修改"的问题。

4.4 解决之前的线程不安全问题

解决线程安全问题,不能一上来就写:

java 复制代码
synchronized

更重要的是先分析问题。

1.找到共享数据

例如:

java 复制代码
static int count;

多个线程都会访问 count ,所以 count 就是共享数据。

2.找到临界区

所谓临界区,可以简单理解为:访问共享数据,并且需要保证并发安全的代码区域。

例如:

java 复制代码
count++;

就是需要重点保护的区域。

3.找到需要保证原子性的操作

我们真正需要保证的是:读取 + 修改 + 写回不能被其他线程在中间插入破坏。

比如说也就是希望:线程1中的读取 → 修改 → 写回作为一个整体完成。

4.为 synchronized 做铺垫

于是我们就需要一种机制:同一时间,只允许一个线程进入关键代码

这就是:互斥。

Java中,synchronized 就可以完成这件事情。

5、synchronized 关键字------监视器锁(Monitor Lock)

5.1 synchronized 的特性

1.互斥

synchronized 最核心的特性就是同一把锁在同一时刻只能由一个线程持有。

例如:

java 复制代码
synchronized (lock) {
    count++;
}

如果:线程A先拿到 lock,那么线程B也想拿 lock,这个时候线程B拿不到,就会等待,直到线程A释放锁。

因此,synchronized 可以保证多个线程对同一共享资源的互斥访问。

2.可重入

synchronized 还是可重入锁。

例如:

java 复制代码
public synchronized void methodA() {
    methodB();
}

public synchronized void methodB() {

}

同一个线程已经持有对象锁,再次获取同一把锁,而不会把自己锁死。

3.synchronized 的基本工作方式

可以简单理解:

text 复制代码
1.线程进入 synchronized

2.尝试获取锁

3.获取成功?

4.如果获取成功,等待执行完成,然后释放锁;如果获取失败的话,就进行等待

Java 官方文档将 synchronized 的同步机制建立在对象的 intrinsic lock / monitor 上,并指出同步可以用于避免线程干扰和内存一致性问题。

5.2 synchronized 使用示例

1.synchronized 修饰代码块

java 复制代码
synchronized (lock) {

    count++;
    
}

例如:

java 复制代码
public class ThreadDemo {

    private static int count = 0;

    private static final Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {

        Thread t1 = new Thread(() -> {

            for (int i = 0; i < 10000; i++) {

                synchronized (lock) {
                    count++;
                }
            }
        });

        Thread t2 = new Thread(() -> {

            for (int i = 0; i < 10000; i++) {

                synchronized (lock) {
                    count++;
                }
            }
        });

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

        t1.join();
        t2.join();

        System.out.println(count);
    }
}

这时候结果就是正确的。

因为线程1、线程2都是使用的是同一把锁,同一时间只有一个线程能够进入临界区。

2.synchronized 修饰实例方法

可以这样:

java 复制代码
public synchronized void increment() {
    count++;
}

它等价于对当前对象的锁进行同步。

可以粗略理解:

java 复制代码
public void increment() {

    synchronized (this) {
        count++;
    }
}

所以,实例 synchronized 方法锁的是当前对象 this。

3.synchronized 修饰静态方法

例如:

java 复制代码
public static synchronized void method() {

}

静态方法没有普通对象的 this。

因此它同步时对应的是当前类的 Class 对象。

可以粗略理解为:

java 复制代码
public static void method() {

    synchronized (MyClass.class) {

    }
    
}

4.synchronized 到底锁的是什么

这个问题非常重要。

例如:

java 复制代码
synchronized (obj) {
}

锁的是 obj 对应的监视器。

如果:

java 复制代码
Object lock1 = new Object();
Object lock2 = new Object();

那么:

java 复制代码
synchronized (lock1)

和:

java 复制代码
synchronized (lock2)

是两把不同的锁。

5.不同锁对象之间的关系

例如:

java 复制代码
synchronized (lock1) {
    count++;
}

另一个线程:

java 复制代码
synchronized (lock2) {
    count++;
}

因为 lock1 不等于 lock2 ,所以它们并不会互相阻塞。

这说明:synchronized 是否互斥,关键不是"都写了 synchronized",而是"是不是同一把锁"。

6.使用 synchronized 解决线程安全问题

之前 count++ 存在问题。

现在上锁之后:

java 复制代码
synchronized (lock) {
    count++;
}

就可以保证读取 + 修改 + 写回是在同一把锁的保护下完成的。

所以解决线程安全问题的基本思路是:

text 复制代码
找到共享数据 -> 找到临界区 -> 选择合适的锁 -> 使用 synchronized 保护

5.3 Java 标准库中的线程安全类

1.什么是线程安全类

如果一个类在多个线程并发使用时,能够按照其设计保证操作安全,那么可以称为线程安全类。Java 标准库中存在一些线程安全的集合和并发工具。

例如:ConcurrentHashMap、CopyOnWriteArrayList、BlockingQueue等。

2.常见线程安全类

例如:

java 复制代码
ConcurrentHashMap<Integer, String> map

用于并发场景下的 Map。

又例如:

java 复制代码
CopyOnWriteArrayList<Integer> list

用于特定并发读多写少场景。

还有:

java 复制代码
BlockingQueue

用于生产者消费者模型。

3.线程安全类的基本使用

例如:

java 复制代码
ConcurrentHashMap<Integer, String> map =
        new ConcurrentHashMap<>();

map.put(1, "Java");
map.put(2, "Thread");

多个线程可以安全地对其执行很多并发操作。

4.使用线程安全类时的注意事项

需要注意的是,线程安全类并不代表你的整个业务逻辑天然线程安全。

例如:

java 复制代码
if (!map.containsKey(key)) {
    map.put(key, value);
}

虽然 containsKey() 和 put() 单独都是安全操作,但判断和插入是两个操作,在多个线程之间仍然可能产生竞态。

所以,类的方法线程安全,不等于任意组合出来的业务操作都是原子的。

5.4 死锁

1.什么是死锁

死锁是多线程中非常经典的问题。

例如:

text 复制代码
线程A持有锁1
线程A等待锁2

线程B持有锁2
线程B等待锁1

于是 A 等 B ,B -> 等 A 两个人互相等。

最终,谁都无法继续执行。

2.死锁产生的典型场景

代码:

java 复制代码
Object lock1 = new Object();
Object lock2 = new Object();

Thread t1 = new Thread(() -> {

    synchronized (lock1) {

        synchronized (lock2) {

        }
    }
});

另一个线程:

java 复制代码
Thread t2 = new Thread(() -> {

    synchronized (lock2) {

        synchronized (lock1) {

        }
    }
});

执行过程中可能:

text 复制代码
t1 持有 lock1
t2 持有 lock2

t1 等 lock2
t2 等 lock1

于是死锁。

3.synchronized 为什么可能产生死锁

因为synchronized是互斥锁。线程获取锁以后必须释放其他线程才能获取。

如果线程A:

text 复制代码
拿着锁1
等待锁2

而线程B:

text 复制代码
拿着锁2
等待锁1

就形成循环等待。

4.死锁产生的必要条件

经典的死锁必要条件包括:

text 复制代码
互斥
占有且等待
不可剥夺
循环等待

其中最容易理解的是:

text 复制代码
A -> 等B
B -> 等A

形成一个循环。

Java 语言规范也明确指出,Java 并不会自动保证所有死锁条件都被检测或避免;程序设计时需要采用合理的锁策略。

5.如何避免死锁

一个重要方法:统一获取锁的顺序。

例如:

所有线程都按照顺序获取。

text 复制代码
lock1 -> lock2

而不要:

text 复制代码
线程A:
lock1 -> lock2
text 复制代码
线程B:
lock2 -> lock1

按顺序获取还可以:

text 复制代码
减少锁的数量
减少锁的持有时间
避免嵌套锁

6.如何发现死锁

实际开发中可以使用:

text 复制代码
JVM 线程转储
jstack
JDK Mission Control
VisualVM
IDE 调试工具

查看线程之间的锁等待关系。

如果发现:

text 复制代码
Thread-1 waiting for lock A
Thread-2 waiting for lock B

同时:

text 复制代码
Thread-1 持有 B
Thread-2 持有 A

就需要重点检查死锁。

6、volatile 关键字

6.1 volatile 的基本概念

volatile 是 Java 中用于处理多线程共享变量的关键字。

例如:

java 复制代码
private volatile boolean running = true;

它主要解决共享变量的内存可见性问题。

但需要特别注意:volatile 不保证复合操作的原子性。

所以volatile和synchronized解决的问题并不完全相同。

6.2 volatile 能保证内存可见性

1.什么是可见性

假设:

java 复制代码
boolean running = true;

线程A:

java 复制代码
while (running) {
}

线程B:

java 复制代码
running = false;

我们希望,线程B修改 running ,线程A可以看到,退出循环。

这就是:可见性。

2.为什么会出现可见性问题

多线程程序中,可能对共享变量存在不同的观察。

现代计算机为了提高性能,会涉及:

text 复制代码
CPU缓存
编译器优化
JIT优化
内存访问重排

因此不能简单认为:一个线程修改变量后,其他线程一定按照我们直觉中的方式立即看到。

3.volatile 如何保证可见性

例如:

java 复制代码
private static volatile boolean running = true;

线程A:

java 复制代码
while (running) {

}

线程B:

java 复制代码
running = false;

使用 volatile 后,对这个变量的写入和后续读取具有明确的内存可见性保证。

所以线程B修改 running 后,线程A能及时的读取到并结束循环,这就是 volatile 的重要作用。

6.3 volatile 不保证原子性

1.什么是原子性

原子性可以简单理解为:一个操作要么完整执行,要么不被其他线程插入破坏。

例如:

java 复制代码
count++;

实际上可以理解成:

text 复制代码
读取 count
 ↓
+1
 ↓
写回 count

它不是一个完整不可分割的原子操作。

2.count++ 为什么不是原子操作

假设 count = 10 。

线程A读取10

线程B读取10

线程A:10 + 1 = 11

线程B:10 + 1 = 11

然后:A写11、B写11

最终看到的是11,正确的结果应该是12才对。

3.volatile 为什么不能解决 count++

即使:

java 复制代码
private static volatile int count;

count++仍然是不安全。

因为 volatile 主要解决的是可见性问题,而 ++ 需要的是原子性。

所以:volatile 不等于 synchronized。

6.4 volatile 与有序性

1.什么是指令重排序

为了优化性能,编译器、JIT 和处理器可能对某些操作进行重新排序。

单线程情况下,只要最终结果符合程序语义,一些重排是允许的。

但是:

text 复制代码
线程A
线程B

同时观察共享数据时,执行顺序就可能影响结果。

2.volatile 对重排序的影响

volatile 不仅仅涉及可见性,还具有与内存顺序相关的语义,可以阻止某些不允许跨越 volatile 读写的重排序。

现在不用深入:

text 复制代码
CPU 指令
内存屏障
StoreLoad
LoadLoad

这些底层细节。

只需要先记住,volatile 除了保证可见性,还对内存操作的有序性提供了重要保证。

6.5 volatile 的典型使用场景

1.线程停止标志

例如:

java 复制代码
private volatile boolean running = true;

线程:

java 复制代码
while (running) {

}

停止需要把 running 置为 false 。

这是 volatile 非常典型的使用方式。

2.共享状态标志

例如:

java 复制代码
private volatile boolean initialized;

一个线程:

java 复制代码
initialized = true;

其他线程:

java 复制代码
if (initialized) {
    // 使用资源
}

需要多个线程及时看到状态变化时,volatile 就可能非常合适。

7、wait 和 notify

这也是非常重要的知识点,它们定义在 Object 中,而不是 Thread 中。

7.1 wait() 方法

1.wait() 的作用

wait() 的作用可以简单理解为:让当前线程进入等待状态,并释放它当前持有的、与调用 wait() 的对象相关联的监视器锁。。

比如说现在使用 lock.wait(),那么释放的只有 lock 这个锁。

例如:

java 复制代码
synchronized (lock) {
    lock.wait();
}

执行流程:

text 复制代码
线程A现在持有 lock
 ↓
线程A执行wait()后
 ↓
释放当前线程的 lock
 ↓
进入等待

2.wait() 的基本使用

java 复制代码
Object lock = new Object();

synchronized (lock) {
    try {
        lock.wait();
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
}

3.wait() 为什么必须配合 synchronized

因为调用某个对象的 wait() 时,当前线程必须已经持有这个对象的监视器锁。

例如:

java 复制代码
synchronized (lock) {
    lock.wait();
}

是正确的。

但是:

java 复制代码
lock.wait();

如果当前线程没有持有 lock 的监视器,就会产生:

text 复制代码
IllegalMonitorStateException

Oracle 官方教程也明确要求 wait() 必须在持有对应对象 intrinsic lock 的情况下调用。

4.wait() 为什么会释放锁

假设:

text 复制代码
线程A
 ↓
拿到 lock
 ↓
发现条件不满足
 ↓
wait()

如果 wait 不释放锁:

text 复制代码
线程A一直拿着锁
 ↓
线程B无法拿到锁
 ↓
线程B无法修改条件
 ↓
线程A永远等

所以 wait() 后必须要释放锁,这样其他线程才能获取到锁,从而执行其他的代码。

7.2 notify() 方法

1.notify() 的作用

java 复制代码
lock.notify();

表示唤醒一个正在等待该对象监视器的线程(随机唤醒的)。

例如:

java 复制代码
synchronized (lock) {
    lock.notify();
}

2.被唤醒的线程会发生什么

注意:

java 复制代码
notify()

并不意味着被唤醒线程马上执行。

例如:

text 复制代码
线程A执行wait()后,进入等待状态

线程B执行notify()

线程A只是从等待队列中被唤醒。

但是线程B此时仍然持有 lock ,线程A必须重新竞争锁 lock ,拿到以后才能继续执行。

Oracle 文档明确说明,被 notify/notifyAll 唤醒的线程并不会立即执行,而是要重新竞争对应的锁。

3.notify 和锁之间的关系

notify() 必须在持有对应对象监视器的情况下调用。

例如:

java 复制代码
synchronized (lock) {
    lock.notify();
}

而不是单独的使用:

java 复制代码
lock.notify();

4.notify 后线程是否立即执行

不是立即执行的,流程是:

text 复制代码
线程B使用notify()
 ↓
线程A被唤醒
 ↓
线程A等待重新获取锁
 ↓
线程B继续执行
 ↓
线程B释放锁
 ↓
线程A竞争锁
 ↓
获得锁
 ↓
继续执行

7.3 notifyAll() 方法

1.notifyAll() 的作用

java 复制代码
lock.notifyAll();

会唤醒所有等待该对象监视器的线程。

2.notify() 和 notifyAll() 的区别

notify() 是随机唤醒一个。

notifyAll() 是唤醒全部。

但是,"唤醒全部"不代表"全部同时执行"。

所有线程仍然需要重新竞争锁。

3.为什么有时候需要 notifyAll()

假设:

text 复制代码
线程A等待条件1
线程B等待条件2
线程C等待条件3

如果 notify() 只能唤醒一个线程,又刚好唤醒的是不满足当前条件的线程,那么它可能再次等待。

这种情况下,notifyAll() 往往更加稳妥:

text 复制代码
唤醒所有等待线程
 ↓
每个线程重新判断条件
 ↓
满足条件的继续
 ↓
不满足条件的继续等待

Oracle 官方的 guarded block 示例也采用 while 配合 wait(),并使用 notifyAll() 进行通知。

7.4 wait 和 sleep 的对比

这是 Java 多线程非常经典的面试题。

对比 wait() sleep()
所属类 Object Thread
是否释放锁 是 否
是否必须 synchronized 是 否
主要作用 线程协作/等待 休眠一段时间
唤醒方式 notify/notifyAll/中断等 时间结束或中断
是否需要条件判断 通常需要 不需要

最重要的一条:

text 复制代码
wait() 是等待并释放锁

sleep() 是等待但不释放锁

Oracle 官方文档对 wait() 的定义明确包含释放对应监视器锁,而 sleep() 是线程的定时等待机制。

7.5 wait/notify 使用中的常见问题

1.为什么通常使用 while 判断条件

java 复制代码
// 推荐
while (!condition) {
    wait();
}

// 不推荐
if (!condition) {
    wait();
}

原因是:线程被唤醒以后,条件不一定仍然满足。

例如:

text 复制代码
线程A等待
 ↓
notifyAll()
 ↓
线程A、B、C都被唤醒
 ↓
竞争锁
 ↓
线程A先拿到锁
 ↓
修改条件
 ↓
线程B拿到锁
 ↓
原来的条件可能已经不成立

因此线程重新获得锁以后必须再次检查条件。

Oracle 官方教程明确建议:wait() 应该放在检查条件的循环中,而不是只检查一次。

2.为什么不能随意调用 wait()

因为 wait() 要求当前线程持有对应对象的监视器锁。

所以必须:

java 复制代码
synchronized (lock) {
    lock.wait();
}

3.线程被唤醒后的再次竞争

流程应该是这样的:

text 复制代码
wait()
 ↓
释放锁
 ↓
等待
 ↓
notify()
 ↓
被唤醒
 ↓
重新竞争锁
 ↓
获得锁
 ↓
继续执行

而不是 notify() 后马上执行。

8、多线程案例

8.1 单例模式

1.什么是单例模式

单例模式是一种设计模式,那么什么又叫做设计模式呢,设计模式就好比象棋中的 "棋谱" ,针对一方的走法,另一个有一些固定的套路来应对他。

单例模式要求:一个类在整个程序运行过程中只能创建一个实例。

例如:

java 复制代码
class Singleton {

		// 单例模式中的"点睛之笔",在类外面进行new操作,就会编译失败
    private Singleton() {
    }
		
		// 静态成员初始化,在类加载的时候触发
    private static final Singleton INSTANCE = new Singleton();
		
		// 后面可以通过这个方法来获取实例
    public static Singleton getInstance() {
        return INSTANCE;
    }
    
}

创建对象的时候:

java 复制代码
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();

这样可以保证这两个对象都相等。

2.饿汉模式

所谓饿汉,就是类加载时就创建对象。

java 复制代码
class Singleton {

    private static final Singleton INSTANCE =
            new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }
    
}

优点:实现简单、线程安全

缺点:对象可能用不到,但已经创建

3.懒汉模式------单线程版

懒汉模式:真正需要对象时才创建。

java 复制代码
class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (instance == null) {
            instance = new Singleton();
        }

        return instance;
    }
    
}

这个代码,在单线程没有问题。

但是多线程环境下:

text 复制代码
线程A:instance == null
线程B:instance == null

A:new
B:new

可能创建多个对象。

所以,这个代码单线程正确,不代表多线程正确。

4.懒汉模式------多线程版

在多线程中,可以这样:

java 复制代码
public static synchronized Singleton getInstance() {

    if (instance == null) {
        instance = new Singleton();
    }

    return instance;
}
text 复制代码
线程A
 ↓
拿锁
 ↓
创建对象

线程B
 ↓
等待

A结束
 ↓
B拿锁
 ↓
发现对象已经存在

这样就可以保证安全。

5.使用 synchronized 改进

synchronized 加在整个方法上,每次获取单例都需要参与同步。

于是可以进一步优化。

6.双重检查锁

经典写法:

java 复制代码
class Singleton {

    private static volatile Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {

        if (instance == null) {

            synchronized (Singleton.class) {

                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }

        return instance;
    }
    
}

这里出现了:

text 复制代码
第一次检查
 ↓
加锁
 ↓
第二次检查
 ↓
创建对象

所以叫双重检查锁。

7.为什么双重检查锁需要 volatile

因为这一句代码:

java 复制代码
instance = new Singleton();

从概念上并不是简单的一步。

可以粗略理解成:

text 复制代码
1. 分配内存
2. 初始化对象
3. 将引用指向对象

volatile 在这里的核心作用是禁止 new Singleton() 中的指令重排序,确保对象在引用赋值之前已经完成了初始化。

因此双重检查锁中的 volatile 是非常重要的。

它不仅提供可见性,还提供必要的有序性保证。

8.2 阻塞队列

1.什么是阻塞队列

普通队列:先进先出(FIFO)

阻塞队列:当队列满了,生产者可以等待;当队列空了,消费者可以等待。

2.为什么需要阻塞

假设队列容量 = 10,如果生产者生产太快导致队列满,这时候生产者不能继续无限添加。

合理做法是等待。

反过来:队列为空,消费者也不能拿数据。

合理做法也是等待。

3.生产者消费者模型

模型如图所示:

生产者:put()

消费者:take()

4.使用 wait/notify 实现阻塞队列

简单实现:

java 复制代码
class MyBlockingQueue {

    private final Queue<Integer> queue = new LinkedList<>();

    private final int capacity = 10;

    public synchronized void put(int value) throws InterruptedException {

        while (queue.size() >= capacity) {
            wait();
        }

        queue.offer(value);

        notifyAll();
    }

    public synchronized int take() throws InterruptedException {

        while (queue.isEmpty()) {
            wait();
        }

        int value = queue.poll();

        notifyAll();

        return value;
    }
    
}

队列满的时候,生产者 wait ,队列空的时候,消费者 wait 。

生产者添加数据以后,使用 notifyAll() 通知消费者。

消费者取走数据以后,使用 notifyAll() 通知生产者。

5.标准库中的 BlockingQueue

Java 已经提供java.util.concurrent.BlockingQueue。

常见实现:

text 复制代码
ArrayBlockingQueue
LinkedBlockingQueue
PriorityBlockingQueue

实际开发中,优先使用标准库提供的并发工具通常比自己从底层实现更加可靠。Oracle 的并发工具文档也强调,标准并发工具能够减少自己实现同步机制时出现死锁、竞态和其他并发错误的风险。

6.使用阻塞队列实现生产者消费者

例如:

java 复制代码
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(10);

生产者:

java 复制代码
Thread producer = new Thread(() -> {

    try {

        for (int i = 0; i < 100; i++) {
            queue.put(i);
            System.out.println("生产:" + i);
        }

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    
});

消费者:

java 复制代码
Thread consumer = new Thread(() -> {

    try {

        while (true) {
            Integer value = queue.take();
            System.out.println("消费:" + value);
        }

    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    
});

这就是非常典型的生产者消费者模型。

8.3 定时器

1.什么是定时器

定时器就是:让某个任务在指定时间执行,或者按照一定周期重复执行。

例如:

text 复制代码
5秒后执行
每隔1秒执行
每天凌晨执行

2.定时任务的基本需求

例如:每隔1秒打印一次当前时间......

可以使用 Java 提供的定时任务机制。

3.标准库中的 Timer

Java 提供了 Timer 和 TimerTask 。

例如:

java 复制代码
Timer timer = new Timer();

timer.schedule(new TimerTask() {

    @Override
    public void run() {
        System.out.println("执行任务");
    }

}, 1000);

表示延迟约 1 秒执行。

4.Timer 和 TimerTask

简单理解:

text 复制代码
Timer表示负责调度

TimerTask表示任务

例如:

java 复制代码
timer.schedule(task, 1000);

5.使用 ScheduledExecutorService 实现定时任务

现代 Java 并发编程中,更常见的是ScheduledExecutorService。

例如:

java 复制代码
ScheduledExecutorService executor =
        Executors.newScheduledThreadPool(1);

executor.schedule(() -> {
    System.out.println("执行任务");
}, 1, TimeUnit.SECONDS);

周期任务:

java 复制代码
executor.scheduleAtFixedRate(() -> {
    System.out.println("周期执行");
}, 0, 1, TimeUnit.SECONDS);

6.简单实现一个定时器

java 复制代码
public class TimerDemo {

    public static void main(String[] args) {

        ScheduledExecutorService executor =
                Executors.newScheduledThreadPool(1);

        executor.scheduleAtFixedRate(() -> {

            System.out.println(
                    System.currentTimeMillis()
            );

        }, 0, 1, TimeUnit.SECONDS);
    }
}

这样就可以:

text 复制代码
启动
 ↓
立即执行
 ↓
1秒
 ↓
执行
 ↓
1秒
 ↓
执行
......

8.4 线程池

1.什么是线程池

假设服务器不断接收到任务:

text 复制代码
任务1
任务2
任务3
......
任务10000

如果每个任务都创建线程,一万个任务就要一万个线程,这就导致效率很低,因为线程创建、销毁、调度本身都有成本。

所以可以提前准备一些线程:

text 复制代码
线程池:worker1、worker2、worker3、worker4

任务来了以后就直接提交线程池,让线程池安排线程执行。

2.为什么需要线程池

线程池主要解决两个问题:

第一:减少线程创建和销毁的开销

线程可以重复利用。

text 复制代码
任务1 → worker1
任务2 → worker1
任务3 → worker1

而不是频繁的创建销毁。

第二:控制线程数量

如果无限制创建线程,任务就会越来越多,从而线程越来越多,这样就导致系统资源被大量消耗。

线程池可以限制和管理线程资源。Oracle 官方文档也指出,线程池能够减少大量线程创建带来的开销,并提供线程资源管理能力。

3.线程池的基本工作原理

提前创建一批线程放在池子里,任务来了复用已有线程,任务结束线程不销毁,等待下一个任务;避免频繁创建 / 销毁线程的开销。

如果线程暂时都忙:

text 复制代码
任务
 ↓
任务队列
 ↓
等待线程处理

因此线程池一般包含:

text 复制代码
工作线程
任务队列
线程数量控制
拒绝策略
任务提交机制

4.Executor

Java 提供Executor接口。

最核心的方法:

java 复制代码
void execute(Runnable command);

例如:

java 复制代码
Executor executor = ...;

executor.execute(() -> {
    System.out.println("执行任务");
});

它把任务提交和任务具体由哪个线程执行进行了分离。

5.ExecutorService

这也是一个接口,但ExecutorService 比 Executor 功能更丰富。

例如:

java 复制代码
ExecutorService executor = Executors.newFixedThreadPool(3)(3);

提交任务:

java 复制代码
executor.submit(() -> {
    System.out.println("执行任务");
});

最后:

java 复制代码
executor.shutdown();

关闭线程池。

6.Executors

Executors 这是一个工具类,提供了一些创建线程池的工厂方法。

例如:

java 复制代码
Executors.newFixedThreadPool(3);

固定大小线程池。

还有:

java 复制代码
Executors.newSingleThreadExecutor();

单线程执行器。

以及:

java 复制代码
Executors.newCachedThreadPool();

等。

Oracle 官方教程也介绍了 fixed、cached、single 等线程池工厂方式。

7.ThreadPoolExecutor

更底层、更灵活的线程池实现。

例如:

java 复制代码
// 参数列表
public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

这里的参数非常重要:

text 复制代码
corePoolSize -> 核心线程数,常驻线程

maximumPoolSize -> 最大线程数(核心 + 非核心总和上限)

keepAliveTime -> 非核心线程空闲存活时间

unit -> keepAliveTime 的时间单位

workQueue -> 阻塞队列,存放等待执行的任务

threadFactory -> 线程工厂,用来创建线程,可以自定义线程名字

handler -> 拒绝策略,任务满了之后的处理方案

8.使用线程池执行任务

完整示例:

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class PoolDemo {

    public static void main(String[] args) {

        ExecutorService executor =
                Executors.newFixedThreadPool(3);

        for (int i = 0; i < 10; i++) {

            int taskId = i;

            executor.submit(() -> {

                System.out.println(
                        Thread.currentThread().getName()
                                + " 执行任务:"
                                + taskId
                );
            });
        }

        executor.shutdown();
    }
}

这里的10个任务并不是创建10个线程,而是3个工作线程 + 10个任务,多个任务由线程池中的工作线程复用执行。

9.简单实现一个线程池

为了真正理解线程池,可以自己实现一个非常简化的版本。

思路:

text 复制代码
任务队列
   ↓
工作线程
   ↓
不断取任务
   ↓
执行任务

例如:

java 复制代码
class MyThreadPool {

    private final BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();

    public MyThreadPool(int count) {

        for (int i = 0; i < count; i++) {

            Thread thread = new Thread(() -> {

                while (true) {

                    try {

                        Runnable task = queue.take();

                        task.run();

                    } catch (InterruptedException e) {

                        Thread.currentThread().interrupt();
                        break;
                    }
                }
            });

            thread.start();
        }
    }

    public void submit(Runnable task) {

        queue.offer(task);
    }
}

使用:

java 复制代码
MyThreadPool pool = new MyThreadPool(3);

for (int i = 0; i < 10; i++) {

    int taskId = i;

    pool.submit(() -> {

        System.out.println(
                Thread.currentThread().getName()
                        + " 执行任务:"
                        + taskId
        );
    });
}

这就是一个非常简化的线程池。

当然,真正的 ThreadPoolExecutor 要复杂得多,它还需要处理这些问题:

text 复制代码
核心线程
最大线程
任务队列
空闲超时
拒绝策略
线程工厂
关闭
任务取消
统计信息

9、对比线程和进程

9.1 线程的优点

1.创建和销毁成本相对较低

相比进程,线程共享所属进程的大部分资源。

因此创建和切换线程通常比创建、切换独立进程更加轻量。

2.线程之间共享进程资源

例如:

text 复制代码
堆
类信息
打开的资源
进程地址空间中的很多数据

可以由同一个进程中的线程共同访问。

但正因为共享,线程安全问题才会出现。

3.线程之间通信更加方便

同一个进程中的线程可以直接访问共享内存中的对象。

多个线程都可以访问。所以线程之间的数据交换可以比较方便。

但是方便并不代表安全。

必须考虑竞态条件、可见性、原子性、同步。

4.适合执行并发任务

例如服务器:

text 复制代码
请求1 -> 线程1
请求2 -> 线程2
请求3 -> 线程3

多个请求可以并发处理。

再例如:

text 复制代码
下载
日志
计算
网络 IO
定时任务

都可以利用多线程进行任务组织。

9.2 进程与线程的区别

对比 进程 线程
定义 程序运行时的资源和执行实体 进程中的执行流
资源 拥有相对独立的资源空间 共享所属进程的大部分资源
内存 不同进程通常相互隔离 同一进程中的线程共享进程资源
通信 通常需要进程间通信机制 可以直接共享内存中的数据
创建成本 相对较高 相对较低
切换成本 通常更高 通常更低
安全性 进程间隔离更强 共享资源带来线程安全问题
使用场景 独立应用、资源隔离 并发执行多个任务

9.3 一个整体的理解

一个操作系统里面有多个进程,进程里面又包含多个线程。

进程之间相对独立,线程之间共享进程资源。

所以线程的优势:

text 复制代码
共享资源方便
创建成本相对较低
适合并发执行

同时也带来了:

text 复制代码
线程安全
死锁
可见性
原子性
同步

等一系列问题。

相关推荐
I Am a robert girl1 小时前
稀有事件估计的迭代去对齐:从源码视角拆解重要性采样新范式
开发语言·python·机器学习·重要性采样·蒙特卡洛方法·稀有事件估计
CarIise1 小时前
Java实训阶段查漏补缺复习笔记2
java·linux·算法
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Feign 的性能优化
java·spring cloud·性能优化
golang学习记2 小时前
rust开发,选VS Code还是RustRover?
开发语言·后端·rust
蛋蛋的就会顺顺的2 小时前
hot100——二叉树
java·数据结构·算法·leetcode·力扣
打工仔折腾 AI2 小时前
System Prompt 替代 Few-shot:用规则约束大模型输出的省钱实践
java·人工智能·python·spring·langchain·prompt·ai agent 实战
打工仔折腾 AI2 小时前
从零写一个CAD 05:以鼠标为中心的滚轮缩放,招法能复用但顺序不能反
开发语言·后端·python·性能优化·计算机外设·swift·ai agent 实战
CarIise2 小时前
Java实训阶段查漏补缺复习笔记1
java·开发语言·笔记
事圆则缓2 小时前
Kotlin 泛型方差实战:out、in、星投影与类型擦除
android·开发语言·kotlin