1. 多线程概念
在 Java 中,当我们启动 main 函数时其实就是启动了一个 JVM 的进程,而 main 函数所在的线程就是这个进程中的一个线程,也称主线程
同一进程的多个线程共享进程的堆和方法区资源,但每个线程有自己的程序计数器、虚拟机栈和本地方法栈
1.1. 了解堆和方法区
堆和方法区是所有线程共享的资源,其中堆是进程中最大的一块内存,主要用于存放新创建的对象 (几乎所有对象都在这里分配内存),方法区主要用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据
1.2. 并发与并行的区别
- 并发:两个及两个以上的作业在同一 时间段 内执行。
- 并行:两个及两个以上的作业在同一 时刻 执行
1.3. 同步和异步的区别
- 同步 : 发出一个调用之后,在没有得到结果之前, 该调用就不可以返回,一直等待
- 异步 :调用在发出之后,不用等待返回结果,该调用直接返回
1.4. 多线程可能带来什么问题
好处
减少了系统上下文切换的开销,调度的开销
大大提高系统整体的并发能力以及性能
坏处
内存泄漏、死锁、线程不安全等
线程安全问题是指多个线程同时访问同一个资源时可能会出现的问题。在多线程编程中,线程安全问题是非常常见的问题,例如竞态条件、死锁、饥饿等问题。为了避免线程安全问题,需要采取一些措施,例如使用锁、信号量、互斥量等机制来保证多个线程之间的同步和互斥
1.5. 多线程的使用场景
- 后台任务,例如:定时向大量(100w以上)的用户发送邮件
- 异步处理,例如:发微博、记录日志等
- 分布式计算
2. 多线程的实现
2.1. 继承 Thread 类实现多线程
继承 Thread 类,再重写 Thread 类中提供的一个 public void run() 方法,多线程要执行的功能都应该在 run()方法中进行定义
run()方法是不能够被直接调用的,要想启动多线程必须使用public void start() 方法完成
// 继承 Thread 类
class MyThread extends Thread {
private String title;
public MyThread(String title){
this.title = title;
}
// 重写run方法
@Override
public void run(){
for (int x = 0 ; x < 10 ; x ++){
System.out.println(this.title + "run" + x);
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
// 通过start方法调用
new MyThread("线程A").start();
new MyThread("线程B").start();
}
}
为什么多线程的启动不直接使用 run() 方法而必须使用 Thread 类中的 start()方法呢?(重点)
new 一个 Thread
,线程进入了新建状态。调用 start()
方法,会启动一个线程并使线程进入就绪状态,当分配到时间片后就可以开始运行了。 start()
会执行线程的相应准备工作,然后自动执行 run()
方法的内容,这是真正的多线程工作。 但是,直接执行 run()
方法,会把 run()
方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作
任何情况下,只要定义了多线程,多线程的启动永远只有一种方案:Thread 类中的start()方法
2.2. Runnable接口实现多线程
由于不再继承Thread父类了,此时的MyThread类中也就不再支持有start()这个继承的方法,可是不使用start()方法无法进行多线程启动
可通过Thread的构造方法:public Thread(Runnable target) 来启动多线程
Runnable接口避免了单继承的局限,所以优先考虑Runnable接口实现多线程,并且永远都是通过Thread类对象启动多线程
从JDK1.8开始,Runnable接口使用了函数式接口定义,所以也可以直接利用Lambda表达式进行线程类实现
class MyThread implements Runnable{
private String title;
public MyThread(String title){
this.title = title;
}
@Override
public void run(){
for (int x = 0 ; x < 10 ; x ++){
System.out.println(this.title + "run" + x);
}
}
}
public class ThreadDemo {
public static void main(String[] args) {
// 通过Thread的构造方法来启动多线程
Thread threadA = new Thread(new MyThread("线程A"));
Thread threadB = new Thread(new MyThread("线程B"));
threadA.start();
threadB.start();
}
}
2.3. Thread与Runnable关系
Thread 类是 Runnable 接口的实现类
public class Thread extends Object implements Runnable {}
Runnable是一个接口,它只有一个run()方法,用于定义线程的任务。Thread是一个类,它实现了Runnable接口,并提供了一些额外的方法,如start()、sleep()、join()等,用于控制线程的执行
在进行 Thread 启动多线程的时候调用的是 start() 方法,而后调用的是 run()方法
但通过 Thread 类的构造方法传递了一个 Runnable 接口对象的时候,那么该接口对象将被 Thread 类中的 target 属性所保存,在 start() 方法执行的时候会调用Thread类中的 run() 方法,而这个 run() 方法去调用 Runnable 接口子类被覆写过的 run() 方法
在Java中,我们通常使用Thread类来创建和启动线程,而Runnable接口则用于定义线程的任务
2.4. Callable接口实现多线程
实现Callable接口,然后重写call方法
class MyThread implements Callable<String> {
// 重写call方法
@Override
public String call() throws Exception{
for (int x=0;x<10;x++){
System.out.println(x);
}
return "completed";
}
}
public class ThreadDemo {
public static void main(String[] args) throws Exception {
FutureTask<String> task = new FutureTask<String>(new MyThread());
new Thread(task).start();
System.out.println("thread of return:" + task.get());
}
}
2.5. Runnable 与 Callable 的区别
Runnable 接口之中只提供有一个 run()方法,并且没有返回值
Callable 接口提供有 call()方法,可以有返回值
3. 线程的生命周期
NEW: 初始状态,线程被创建出来但没有被调用 start()
,线程被调用 start()
会进入就绪状态
RUNNABLE: 运行状态,线程被调用了,等待状态的线程得到了时间片
BLOCKED :阻塞状态,需要等待锁释放
WAITING:等待状态,表示该线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING:超时等待状态,可以在指定的时间后自行返回而不是像 WAITING 那样一直等待
TERMINATED:终止状态,表示该线程已经运行完毕
4. 多线程常用操作
主线程可以创建若干个子线程,主线程负责处理整体流程,而子线程负责处理耗时流程
4.1. 线程的命名
方法 | 功能 |
---|---|
构造方法 public Thread(Runnable target,String name) | 命名线程 |
public final void setName(String name) | 设置名字 |
public final String getName() | 获取名字 |
public static Thread current Thread() | 获取当前线程 |
class MyThread implements Runnable {
@Override
public void run() {
// 获取当前线程名
System.out.println(Thread.currentThread().getName());
}
}
public class ThreadDemo {
public static void main(String[] args) throws Exception {
MyThread task = new MyThread();
new Thread(task,"线程1").start();
new Thread(task,"线程2").start();
// 未命名线程
new Thread(task).start();
}
}
4.2. 线程休眠
使用休眠的处理,让一个线程暂缓执行。必须进行异常的抛出
public static void sleep(long millis) throws InterruptedException
休眠的主要特点是可以自动实现线程的唤醒,以继续进行后续的处理。需要注意的是,如果有多个线程对象,那么休眠也是有先后顺序的
4.3. sleep() 方法和 wait() 方法对比
共同点 :两者都可以暂停线程的执行
区别 :
sleep()
方法没有释放锁,而wait()
方法释放了锁 。wait()
通常被用于线程间交互/通信,sleep()
通常被用于暂停执行。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的notify()
或者notifyAll()
方法。sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用wait(long timeout)
超时后线程会自动苏醒。sleep()
是Thread
类的静态本地方法,wait()
则是Object
类的本地方法
4.4. 线程中断
这种打断肯定是由其他线程完成的,中断线程必须进行异常的抛出
方法 | 功能 |
---|---|
public boolean isInterrupted() | 判断线程是否被中断 |
public void interrupt() | 中断线程执行 |
4.5. 线程强制运行
指的是当满足于某些条件之后,某个线程对象可以一直独占资源,直到该线程的程序执行结束
正常情况下,主线程与子线程是交替执行的(谁抢到CPU谁就执行),如果希望某个线程独占执行,可使用 jion()方法
4.6. 线程礼让
将资源让出去让别的线程先执行
每一次调用yield() 方法都只会礼让一次当前的资源
public static void yield()
4.7. 线程优先级
优先级越高越有可能先执行
在进行优先级定义的时候都是通过int 型的数字来完成的,而对于此数字的选择在Thread类里面就定义有三个常量:
最高优先级:public static final int MAX PRIORITY,10
中等优先级:public static final int NORM PRIORITY,5
最低优先级:public static final int MIN PRIORITY,1
方法 | 功能 |
---|---|
public finial void setPriority(int newPriority) | 设置优先级 |
public final int getPriority() | 获取优先级 |
主线程是属于中等优先级,而默认创建的线程也是中等优先级。优先级高的可能先执行而不是绝对先执行
5. 线程的同步与死锁
5.1. 线程同步
当多个线程同时访问共享资源时,可能会发生竞争条件,导致数据不一致或其他问题。使用 synchronized 关键字可以确保在任何时候只有一个线程可以访问共享资源(即上锁),从而避免竞争条件
同步代码块
synchronized(同步对象){
同步代码操作 ;
}
一般要进行同步对象处理的时候可以采用当前对象 this 进行同步
public void run() {
synchronized(this){
......
.....
}
}
同步方法,只需要在方法定义上使用 synchronized 关键字即可
同步实际上会造成性能的降低
5.2. 线程死锁
死锁造成的主要原因是彼此都在互相等待着,等待着对方先让出资源
过多的同步会造成死锁
5.3. 释放锁(重点)
在Java中,当同步方法或代码块执行完毕时,锁会自动释放。此外,如果线程在同步方法或代码块中抛出异常,则锁也会自动释放。如果需要在同步方法或代码块中提前释放锁,可以使用 wait 和 notify 方法来实现。具体来说,当一个线程调用 wait 方法时,它会释放对象的锁,并进入等待状态,直到另一个线程调用相同对象的 notify 方法来唤醒它。当一个线程调用 notify 方法时,它会通知等待该对象的线程,使其中的一个线程从等待状态中恢复并重新获取对象的锁
public class MyClass {
private boolean flag = false;
public synchronized void doSomething() {
while (flag) {
try {
wait(); // release lock and wait
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// do something
flag = true;
notify(); // notify waiting threads
}
public synchronized void doSomethingElse() {
while (!flag) {
try {
wait(); // release lock and wait
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// do something else
flag = false;
notify(); // notify waiting threads
}
}
在上面的示例中,doSomething 和 doSomethingElse 方法都是同步的,并使用 wait 和 notify 方法来实现同步。当一个线程调用 doSomething 方法时,它会执行其中的代码,并将 flag 设置为 true,然后调用 notify 方法来唤醒doSomethingElse
6. 生产者-消费者模型
该模型用于解决多个线程之间的协作问题。在该模型中,有两种类型的线程:生产者和消费者。生产者线程负责生成数据并将其放入共享缓冲区中,而消费者线程负责从缓冲区中取出数据并进行处理。
Java提供了多种机制来实现生产者-消费者模型,其中最常用的是使用wait()、notify()和notifyAll()方法实现线程间的通信
方法 | 功能 |
---|---|
public final void wait() throws InterruptedException | 死等 |
public final void wait(long timeout) throws InterruptedException | 设置等待时间 |
public final void notify() | 唤醒第一个等待线程 |
public final void notifyAll() | 唤醒全部等待线程 |
以下是一个使用wait() 和notify() 方法的示例:
public class ProducerConsumer {
// 定义一个缓冲区
private List<Integer> buffer = new ArrayList<>();
private int capacity = 10;
public void produce() throws InterruptedException {
int value = 0;
while (true) {
synchronized (this) {
while (buffer.size() == capacity) {
wait();
}
System.out.println("Producer produced " + value);
buffer.add(value++);
notify();
Thread.sleep(1000);
}
}
}
public void consume() throws InterruptedException {
while (true) {
synchronized (this) {
while (buffer.size() == 0) {
wait();
}
int value = buffer.remove(0);
System.out.println("Consumer consumed " + value);
notify();
Thread.sleep(1000);
}
}
}
}
以上示例中,创建了一个ProducerConsumer类,其中包含一个共享缓冲区和两个方法:produce()和consume()。在produce()方法中,使用synchronized关键字同步访问共享缓冲区,并在缓冲区已满时使用wait() 方法使生产者线程等待。在生产者线程生成数据并将其放入缓冲区后,我们使用notify()方法通知消费者线程可以开始消费数据。在consume()方法中,我们使用类似的方式同步访问共享缓冲区,并在缓冲区为空时使用wait() 方法使消费者线程等待。在消费者线程从缓冲区中取出数据并进行处理后,我们使用notify()方法通知生产者线程可以开始生成数据。需要注意的是,wait()和notify()方法必须在synchronized块中使用,以确保线程安全。
7. 多线程深入
7.1. 停止线程
不要直接用stop()方法
使用 flag 标志判断的方法来控制线程的停止
this.flag = true;
while(flag){
...
...
}
this.flag = false;
7.2. 守护线程
当一个进程中有线程还在执行的时候,守护线程将一直存在,并且运行在后台状态
如果程序执行完毕了,守护线程也就消失了,在整个的 JVM 里面最大的守护线程就是 GC 线程
在 Thread 类里面提供有如下的守护线程的操作方法
方法 | 功能 |
---|---|
public final void setDaemon(boolean on) | 设置为守护线程 |
public final boolean isDaemon() | 判断是否为守护线程 |
7.3. volatile关键字
volatile
关键字可以保证变量的可见性。具体来说是当一个变量被声明为 volatile 时,它的值会被直接存储在主内存中,而不是存储在线程的工作内存中。这样,当一个线程修改了变量的值时,它会立即同步到主内存中,从而使其他线程可以看到这个修改
在正常进行变量处理的时候往往会经历如下的几个步骤:
获取变量原有的数据内容副本
利用副本为变量进行数学计算
将计算后的变量,保存到原始空间之中
如果一个属性上追加了volatile 关键字,表示不使用副本而是直接操作原始变量
volatile
关键字能保证数据的可见性,但不能保证数据的原子性。synchronized
关键字两者都能保证
public class MyClass {
private volatile int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
8. 乐观锁和悲观锁
8.1. 悲观锁
Java 中synchronized
和ReentrantLock
等独占锁就是悲观锁思想的实现
共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程
悲观锁通常用于多写场景,避免频繁失败和重试影响性能
8.2. 乐观锁
乐观锁通常用于多读场景,避免频繁加锁影响性能,大大提升了系统的吞吐量
9. 线程池
经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大
提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中,可以避免频繁创建销毁,实现重复利用
优点
性能优化、防止OOM(防止应用创建很多线程导致内存溢出)
9.1. 创建线程池
通过ThreadPoolExecutor
构造函数来创建(推荐)
ThreadPoolExecutor 是 Executor接口的一个实现
ThreadPoolExecutor
3 个最重要的参数:
corePoolSize
:核心线程数。任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
:最大线程数。任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
:新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
ThreadPoolExecutor
其他常见参数 :
keepAliveTime
:线程池中的线程数量大于corePoolSize
的时候,如果这时没有新的任务提交,核心线程外的线程不会立即销毁,而是会等待,直到等待的时间超过了keepAliveTime
才会被回收销毁unit
:keepAliveTime
参数的时间单位。threadFactory
:executor 创建新线程的时候会用到。handler
:饱和策略
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class MyClass {
public static void main(String[] args) {
// create a thread pool with 5 threads
ThreadPoolExecutor executor = new ThreadPoolExecutor(
5, // core pool size
10, // maximum pool size
60, // keep-alive time
TimeUnit.SECONDS, // time unit for keep-alive time
new ArrayBlockingQueue<Runnable>(100) // task queue
);
// submit tasks to the thread pool
for (int i = 0; i < 20; i++) {
executor.submit(new MyTask(i));
}
// shutdown the thread pool
executor.shutdown();
}
}
class MyTask implements Runnable {
private int taskId;
public MyTask(int taskId) {
this.taskId = taskId;
}
public void run() {
System.out.println("Task #" + taskId + " is running.");
}
}
在上面的示例中,使用 ThreadPoolExecutor 构造函数创建了一个线程池,它包含5个核心线程和10个最大线程。当线程池中的线程数量超过核心线程数时,新的任务会被放入任务队列中等待执行。如果任务队列已满,新的任务会创建新的线程来执行,直到线程数量达到最大线程数。线程空闲时间为60秒,超过这个时间后,空闲线程会被销毁。任务队列使用了一个大小为100的有界阻塞队列。最后,我们使用 executor.submit 方法提交任务到线程池中,并使用 executor.shutdown 方法关闭线程池
通过 Executor
框架的工具类 Executors
来创建(不推荐)
Executors
返回线程池对象的弊端,,会有 OOM 风险。OOM是指当程序尝试使用超出可用内存的内存时,会抛出OutOfMemoryError异常
9.2. 线程池的饱和策略
指当线程池中的线程数量达到最大值并且任务队列已满时,新的任务应该如何处理
Java中的线程池提供了四种饱和策略:
- AbortPolicy(默认):当线程池已满并且任务队列已满时,新的任务会抛出 RejectedExecutionException异常
-
CallerRunsPolicy:当线程池已满并且任务队列已满时,新的任务会在提交任务的线程中直接执行
-
DiscardPolicy:当线程池已满并且任务队列已满时,新的任务会被丢弃,不会抛出异常也不会执行
- DiscardOldestPolicy:当线程池已满并且任务队列已满时,新的任务会替换任务队列中最早的任务
9.3. 线程池常用的阻塞队列
新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中
LinkedBlockingQueue 是一个无界队列,它可以存储任意数量的任务。当线程池中的线程数量达到核心线程数时,新的任务会被放入 LinkedBlockingQueue中等待执行。由于LinkedBlockingQueue 是无界队列,因此它可以存储任意数量的任务,但是当任务数量过多时,可能会导致内存溢出
ArrayBlockingQueue 是一个有界队列,它可以存储固定数量的任务。当线程池中的线程数量达到核心线程数时,新的任务会被放入ArrayBlockingQueue中等待执行。由于 ArrayBlockingQueue是有界队列,因此它可以避免任务数量过多导致的内存溢出问题。但是当任务数量超过队列大小时,新的任务会被拒绝执行,除非使用合适的饱和策略
9.4. 给线程池命名
默认情况下创建的线程名字类似 pool-1-thread-n
这样
自己实现 ThreadFactor
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 线程工厂,它设置线程名称,有利于我们定位问题。
*/
public final class NamingThreadFactory implements ThreadFactory {
private final AtomicInteger threadNum = new AtomicInteger();
private final ThreadFactory delegate;
private final String name;
/**
* 创建一个带名字的线程池生产工厂
*/
public NamingThreadFactory(ThreadFactory delegate, String name) {
this.delegate = delegate;
this.name = name; // TODO consider uniquifying this
}
@Override
public Thread newThread(Runnable r) {
Thread t = delegate.newThread(r);
t.setName(name + " [#" + threadNum.incrementAndGet() + "]");
return t;
}
}
9.5. 合理设定线程池的大小
有一个简单并且适用面比较广的公式:
- CPU 密集型任务(N+1): 这种任务消耗的主要是 CPU 资源,可以将线程数设置为 N(CPU 核心数)+1。比 CPU 核心数多出来的一个线程是为了防止线程偶发的缺页中断,或者其它原因导致的任务暂停而带来的影响。一旦任务暂停,CPU 就会处于空闲状态,而在这种情况下多出来的一个线程就可以充分利用 CPU 的空闲时间。
- I/O 密集型任务(2N): 这种任务应用起来,系统会用大部分的时间来处理 I/O 交互,而线程在处理 I/O 的时间段内不会占用 CPU 来处理,这时就可以将 CPU 交出给其它线程使用。因此在 I/O 密集型任务的应用中,我们可以多配置一些线程,具体的计算方法是 2N。
9.6. 动态修改线程池的参数
开源线程池:DynamicTp、Hippo4j
9.7. 相关API
Executors类
线程池的工厂类,用于创建不同类型的线程池
ThreadPoolExecutor类
ThreadPoolExecutor类是Java提供的一个灵活的线程池实现
方法 | 功能 |
---|---|
void execute(Runnable command) | 执行任务,没有返回值,一般用来执行Runnable |
<T> Future<T> submit(Callable<T> task) |
执行任务,有返回值,一般来执行Callable |
void shutdown() | 关闭连接池 |
10. Future 模式
核心思想是异步调用,主要用在多线程领域,并非 Java 语言独有
10.1. Future
在 Java 中,Future 类只是一个泛型接口
将耗时任务交给Future处理,任务执行期间可以去做其他事情。并且,在这期间还可以取消任务以及获取任务的执行状态。一段时间之后,可以从 Future 里直接取出任务执行结果
方法
方法 | 功能 |
---|---|
取消任务 | boolean cancel(boolean mayInterruptIfRunning) |
判断任务是否被取消 | boolean isCancelled() |
判断任务是否已经执行完成 | boolean isDone() |
获取任务执行结果 | V get() throws InterruptedException, ExecutionException |
指定时间内获取任务执行结果 | V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException |
10.2. FutureTask
FutureTask 提供了 Future 接口的基本实现,常用来封装 Callable 和 Runnable,具有取消任务、查看任务是否执行完成、获取任务执行结果的方法。ExecutorService.submit() 方法返回的其实就是 Future 的实现类 FutureTask
FutureTask 不光实现了 Future 接口,还实现了Runnable 接口,因此可以作为任务直接被线程执行
FutureTask 有两个构造函数,可传入 Callable 或者 Runnable 对象。实际上,传入 Runnable 对象也会在方法内部转换为Callable 对象