多线程基础知识点梳理

基础概念

  1. 进程(process):进程是计算机中的一个任务,比如打开浏览器、IntelliJ IDEA。
  2. 线程(thread):线程是进程内部的子任务。比如IDEA在敲代码的同时还能自动保存、自动导包,都是子线程做的。

进程和线程的关系就是一个进程包含一个或多个线程。
线程是操作系统调度的最小任务单位。线程自己不能决定什么时候执行,由操作系统决定什么时候调度。因此多线程编程中,代码的先后顺序不代表代码的执行顺序。

多线程有什么好处?

  1. 提高应用程序的性能。异步编程让程序更快的响应。
  2. 提高CPU利用率。一个线程阻塞,另一个线程继续执行,充分利用CPU。

多线程存在什么问题?
多线程会带来安全问题,比如多个线程读写一个共享变量,会出现数据不一致的问题。

什么时候考虑用多线程?

  1. 高并发。系统在同一时间要处理多个任务时,需要用多线程。
  2. 很耗时的操作。如文件读写,异步执行不让进程阻塞。
  3. 不影响方法主流程逻辑,但又影响接口性能的操作,如数据同步,使用异步方式能提高接口性能。

创建线程的方式

多线程的创建方法有很多种:

  • 继承Thread
  • 实现Runnalble接口
  • 实现Callable接口
  • 使用线程池
  • 使用 Java8 的CompletableFuture
  • 使用 Spring 框架提供的TaskExecutor接口

线程创建的本质是两点:

  1. 实现Runnalble接口并重写run()方法;
  2. 通过Thread类的start()方法启动新线程。

1. 继承Thread类

public class ThreadTest extends Thread {

    @Override
    public void run() {
        System.out.println("新线程开始...");
    }

    public static void main(String[] args) {
        ThreadTest t = new ThreadTest();
	    t.start();
        System.out.println("main线程结束...");
    }
}
main线程结束...
新线程开始...

启动一个新线程总是调用它的start()方法,而不是run()方法;ThreadTest子线程启动后,它跟main就开始同时运行了,谁先执行谁后执行由操作系统调度。所以多线程代码的执行顺序跟代码顺序无关。

2. 实现Runnable接口

实现Runnable接口,重写run()方法,作为构造器参数传给Thread,调用start()方法启动线程。

public class Test {
    public static void main(String[] args) {
        RunnableThread r = new RunnableThread();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class RunnableThread implements Runnable {
    @Override
    public void run() {
        System.out.println("新线程开始...");
    }
}

两种方式对比

一般推荐使用实现Runnable的方式来创建新线程,它的优点有:

  1. Java中只有单继承,接口则可以多实现。如果一个类已经有父类,它就不能再继承Thread类了,继承了Thread类就不能再继承其他类,有局限性。实现Runnable接口则没有局限性。
  2. 实现Runnable接口的类具有共享数据的特性,它可以同时作为多个线程的执行单位(target),此时多个线程操作的是同一个对象的run方法,这个对象所有变量在这几个线程间是共享的。而继承Thread的方式做不到,比如A extends Thread,每次启动线程都是new A().start(),每次的A对象都不同。

3. 实现Callable接口

Callable区别于Runnable接口的点在于,Callable的方法有返回值,还能抛出异常。

public interface Callable<V> {
    V call() throws Exception;
}

Callable的用法:

  • 配合FutureTask一起使用。
  • 使用线程池时,调用ExecutorService#submit方法,返回一个Future对象。

Future和FutureTask

使用Callable接口前,需要了解FutureFutureTask
在Java并发编程中,Future接口代表着异步计算结果。它定义的方法有:

  • get():获取异步执行的结果。调用get()方法时,如果异步任务已经完成,就直接返回结果。如果异步任务还没完成,那么get()方法会阻塞,一直等待任务完成才返回结果,这一点也是FutureTask的缺点。
  • get(long timeout, TimeUnit unit):获取结果,但只等待指定的时间;添加超时时间可以让调用线程及时释放,不会死等。
  • cancel(boolean mayInterruptIfRunning):取消当前任务;mayInterruptIfRunning的作用是,当任务在执行中被取消,如果mayInterruptIfRunning == true就中断任务,否则不中断,任务可继续执行。
  • isCancelled():任务在执行完成前被取消,返回true,否则返回false
  • isDone():判断任务是否已完成。任务完成包括:正常完成、抛出异常而完成、任务被取消。

FutureTask实现了RunnableFutureRunnableFuture又实现了FutureRunnableFuture接口从名字来看,它同时具有RunnableFuture接口的的能力。FutureTask提供2个构造器,同时支持Callable方式和Runnable方式的任务。FutureTask可作为任务传给Thread的构造器,或者线程池的submit方法。FutureTask也有局限性。比如get()方法会阻塞调用线程;不能将多个异步计算结果合并到一起等等,针对这些局限,Java8提供了CompletableFuture

通常情况下,使用异步方式执行的任务都是比较耗时的,因此在Future异步执行的同时,可以做一些其他事,这样两件事情就能同时做了,不至于因为get()方法的阻塞让另一个任务等待。如果Future任务发生异常,那get()方法会一直获取不到结果,会一直阻塞,因此实际情况下,要对线程体做异常处理;或者使用另一个重载方法get(long timeout, TimeUnit unit)

CallableFutureTask一起使用的例子:

public class CallableTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // 创建Callable接口实现类的对象
        CallableThread sumThread = new CallableThread();

        // 创建FutureTask对象
        FutureTask<Integer> futureTask = new FutureTask<>(sumThread);

        // 将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
        new Thread(futureTask).start();

        // 通常在future任务执行期间,可以继续做别的事情
        doSomethingElse();

        // 获取Callable中call方法的返回值
        Integer sum = futureTask.get();
        System.out.println("总和为" + sum);

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

    private static void doSomethingElse() throws InterruptedException {
        Thread.sleep(1000);
        System.out.println("do something else");
    }
}

class CallableThread implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        Thread.sleep(2000); // 等待2s验证futureTask.get()是否等待
        return sum;
    }
}
do something else
总和为5050
main线程结束

在JDK源码中可看到get()方法执行时,会判断线程状态如果是未完成,会进入一个无限循环,直到任务完成才返回执行结果。

public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING) // 如果未完成,则等待完成
        s = awaitDone(false, 0L);
    return report(s);
}

private int awaitDone(boolean timed, long nanos) throws InterruptedException {
    // ...
    for (; ; ) { // 无限循环,直到任务完成
        // ...
        int s = state;
        if (s > COMPLETING) {
            if (q != null)
                q.thread = null;
            return s;
        }
	// ...
    }
}

4. 线程池

👉 线程池原理

5. 使用CompletableFuture

CompletableFuture是Java8提供的一个类,是对Future的扩展和增强。CompletableFuture的特点是处理多个异步任务之间的关联时变得方便了。它定义的方法结合了Java8函数式编程的风格,可以很方便的编写申明式的异步代码,比如thenRunthenAcceptthenSupplythenCombine等,还能方便的处理异常。它最简单且常用的两个方法就是:

  • runAsync(Runnable runnable, Executor executor):执行一个异步任务,任务无返回值。
  • supplyAsync(Supplier<U> supplier, Executor executor):执行一个异步任务,任务有返回值。

实际业务使用时都会使用自定义的线程池,不会使用默认线程池。

6. 使用TaskExecutor

Spring框架的TaskExecutor接口与JDK的Executor是等价的,TaskExecutor继承自JDK的Executor。Spring框架内置有很多TaskExecutor的实现,常见的有SimpleAsyncTaskExecutorThreadPoolTaskExecutor。现在以ThreadPoolTaskExecutor为例,看下如何使用Spring的线程池类。

使用ThreadPoolTaskExecutor

1.第一步,向Spring容器注入ThreadPoolTaskExecutor的实例。ThreadPoolTaskExecutor是对JDK的ThreadPoolExecutor的包装,所以它跟配置JDK的线程池的参数一样。

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:context="http://www.springframework.org/schema/context"
       xmlns:task="http://www.springframework.org/schema/task"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
        https://www.springframework.org/schema/beans/spring-beans.xsd
        http://www.springframework.org/schema/context
        https://www.springframework.org/schema/context/spring-context.xsd
        http://www.springframework.org/schema/task
        http://www.springframework.org/schema/task/spring-task-3.1.xsd">

    <context:component-scan base-package="com.xfcoding.demo"/>

    <bean id="myTaskExecutor" class="org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor">
        <!--核心线程数-->
        <property name="corePoolSize" value="10"/>
        <!--最大线程数-->
        <property name="maxPoolSize" value="10"/>
        <!--非核心线程存活时间-->
        <property name="keepAliveSeconds" value="60"/>
        <!--阻塞队列长度-->
        <property name="queueCapacity" value="20"/>
        <!--拒绝策略-->
        <property name="rejectedExecutionHandler">
            <bean class="java.util.concurrent.ThreadPoolExecutor$CallerRunsPolicy"/>
        </property>
    </bean>
</beans>

2.一个业务实体类,它会异步执行某个任务。

@Component
public class AsyncExecutorDemo {
    @Autowired
    private ThreadPoolTaskExecutor taskExecutor;

    public void printMessage(String message) {
        taskExecutor.execute(() -> System.out.println(Thread.currentThread().getName() + " " + message));
    }
}

3.其他类调用它提供的方法完成任务,用main方法模拟一下:

public class SpringTaskExecutorTest {
    public static void main(String[] args) {
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("applicationContext.xml");

        System.out.println(Thread.currentThread().getName() + " start...");

        AsyncExecutorDemo asyncExecutorDemo = context.getBean(AsyncExecutorDemo.class);
        asyncExecutorDemo.printMessage("message01");
        asyncExecutorDemo.printMessage("message02");
        asyncExecutorDemo.printMessage("message03");

        System.out.println(Thread.currentThread().getName() + " end...");
    }
}

一种可能结果:

main start...
main end...
myTaskExecutor-2 message02
myTaskExecutor-3 message03
myTaskExecutor-1 message01

上面这种显式的通过ThreadPoolTaskExecutor调用execute方法执行异步任务,跟使用JDK的ThreadPoolExecutor没什么两样。如果使用@Async注解,会让代码更简洁。

使用@Async

要使用@Async,需要添加使用申明。如果使用 xml 配置文件,则要添加<task:annotation-driven/>及相关命名空间,如果是用配置类,则使用注解申明@EnableAysnc

同时,<task:executor>标签默认就会实例化一个ThreadPoolTaskExecutor

上面 xml 文件中对ThreadPoolTaskExecutor的初始化可以替换为:

<task:executor id="myTaskExecutor" pool-size="10-20" keep-alive="60" queue-capacity="20"/>

这里的pool-size如果只配置一个值,代表核心线程数和最大线程数一致;还可以配置成 min-max 的形式,min 代表核心线程数,max 代表最大线程数。

如果在<task:annotation-driven/>标签中使用executor属性指定了线程池,那么用@Async注解修饰的方法就会使用该线程池。如果没有指定线程池,@Async则使用默认的线程池SimpleAsyncTaskExecutor,但是业务开发时不可能使用默认的,都会自定义线程池。

<!--启用任务注解, 默认使用myTaskExecutor线程池-->
<task:annotation-driven executor="myTaskExecutor"/>

也可以在@Async中特别指定使用其他线程池。

@Async("otherExecutor")
void doSomething(String s) {
    // this will be executed asynchronously by "otherExecutor"
}

Thread 类的常用方法

  • start():启动当前线程
  • currentThread():返回当前代码执行的线程
  • yield(): 释放当前CPU的执行权
  • join()join()方法可以让其他线程等待,直到自己执行完了,其他线程才继续执行。
  • setDaemon(boolean on):设置守护线程,也叫后台线程。JVM退出时,不必关心守护线程是否已结束。
  • interrupt():中断线程。
  • sleep(long millis):让线程睡眠指定的毫秒数,在指定时间内,线程是阻塞状态
  • isAlive():判断当前线程是否存活。
public class ThreadJoinTest {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            System.out.println("hello");
        });
        System.out.println("start");
        t.start();
        t.join();
        System.out.println("end");
    }
}
start
hello
end

线程的状态

  • New:新创建的线程,尚未执行;
  • Runnable:运行中的线程,正在执行run()方法的Java代码;
  • Blocked:运行中的线程,因为某些操作被阻塞而挂起;
  • Waiting:运行中的线程,因为某些操作在等待中;
  • Timed Waiting:运行中的线程,因为执行sleep()方法正在计时等待;
  • Terminated:线程已终止,因为run()方法执行完毕。

当线程启动后,它可以在RunnableBlockedWaitingTimed Waiting这几个状态之间切换,直到最后变成Terminated状态,线程终止。

线程终止的原因有:

  • 线程正常终止:run()方法执行到return语句返回;
  • 线程意外终止:run()方法因为未捕获的异常导致线程终止;
  • 对某个线程的Thread实例调用stop()方法强制终止(过时方法,不推荐使用)。

volatile

volatile关键字解决的是共享变量的可见性问题:当一个线程修改了某个共享变量的值,其他线程能够立刻看到修改后的值。它的原理是什么呢?这涉及到Java的内存模型(JMM)。

在 Java 内存模型中,内存有主内存、工作内存之分。主内存保存共享变量,比如类变量、实例变量;工作内存是每个线程独有的,它保存私有变量,比如方法局部变量。

在 Java 虚拟机中,当一个线程访问共享变量时,它会先获取共享变量的一个副本,并保存在自己的工作内存中。如果线程修改了共享变量的值,虚拟机会在某个时刻把修改后的值回写到主内存,但是,这个时间是不确定的!

这会导致一个问题,如果一个线程更新了某个共享变量,另一个线程读取到的值可能还是更新前的。例如,主内存的变量a = true,线程1执行a = false时,它在此刻仅仅是把变量a的副本变成了false,主内存的变量a还是true,在JVM把修改后的a回写到主内存之前,其他线程读取到的a的值仍然是true,这就造成了多线程之间共享的变量不一致。

因此,volatile关键字的目的是告诉虚拟机:

  • 每次访问变量时,总是获取主内存的最新值;
  • 每次修改变量后,立刻回写到主内存。

volatile解决可见性问题,但不能保证原子性,原子性问题需要根据实际情况做同步处理,因为原子性问题本质上是多线程调度的不确定性造成的。
那什么是原子性呢?原子性就是多个操作要么全部成功,要么全部失败。比如,在并发场景下,如果两个线程对一个数执行了 +1 操作,那么别的线程读取到该数应该是 +2 的结果。

线程同步

什么叫线程同步?对于多线程的程序来说,同步指的是在一定的时间内只允许某一个线程访问某个资源。

在Java中,最常见的方法是用synchronized关键字实现同步效果。

synchronized

synchronized可以修饰实例方法、静态方法、代码块。

synchronized的底层是使用操作系统的互斥锁(mutex lock)实现的,它的特点是保证内存可见性、操作原子性。

  • 内存可见性:可见性的原理还要回到Java内存模型(上面JMM的那张图)。synchronized上锁时,会清空工作内存中变量的值,去主内存中获取该变量的值;解锁时,会把工作内存中变量的值同步回主内存中
  • 操作原子性:持有同一个锁的两个同步块只能串行地执行。

使用synchronized解决了多线程同步访问共享变量的正确性问题。但是,它的缺点是带来了性能下降。因为synchronized代码块无法并发执行。此外,加锁和解锁需要消耗一定的时间,所以,synchronized会降低程序的执行效率。

不需要synchronized的操作

JVM规范定义了几种原子操作:

  • 基本类型(longdouble除外)赋值,例如:int n = 1
  • 引用类型赋值,例如:List list = anotherList

longdouble是64位(8字节)数据,在32位和64位操作系统上是不一样的。JVM没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把longdouble的赋值作为原子操作实现的。

posted @ 2023-05-17 10:43  yfhu  阅读(944)  评论(0编辑  收藏  举报