4、多线程

程序、进程、线程的区别

多线程程序的优点

  1. 提高应用程序的响应。对图形化界面更有意义,可增强用户体验。

  2. 提高计算机系统CPU的利用率

  3. 改善程序结构。将既长又复杂的进程分为多个线程,独立运行,利于理解和修改

何时需要多线程

  1. 程序需要同时执行两个或多个任务。
  2. 程序需要实现一些需要等待的任务时,如用户输入、文件读写操作、网络操作、搜索等。
  3. 需要一些后台运行的程序时。

并发和并行

创建多线程

一个Java应用程序java.exe,其实至少两个线程:

方式一:继承Thread类

  1. 创建一个继承于Thread类的子类
  2. 重写Thread类的run():将此线程执行的操作声明在run()中 (线程要完成的任务)
  3. 创建Thread类的子类的对象
  4. 通过此对象调用start():启动当前线程,调用当前线程的run()
// 继承Thread
static class MyThread extends Thread{
    // 重写run
    @Override
    public void run() {
        while (true){
            System.out.println(Thread.currentThread().getName());
        }
    }
}

public static void main(String[] args) throws Exception {
    // 创建两个线程
    MyThread t1 = new MyThread();
    t1.start();
    MyThread t2 = new MyThread();
    t2.start();
}

方式二:实现Runnable接口

  1. 创建一个实现了Runnable接口的类
  2. 实现类去实现Runnable中的抽象方法:run()
  3. 创建实现类的对象
  4. 将此对象作为参数传递到Thread类的构造器中,创建Thread类的对象
  5. 通过Thread类的对象调用start()
// 实现Runnable
static class MyThread implements Runnable{
    // 实现run
    @Override
    public void run() {
        while (true){
            System.out.println(Thread.currentThread().getName());
        }
    }
}

public static void main(String[] args) throws Exception {
    // 创建两个线程
    Thread t1 = new Thread(new MyThread());
    t1.start();
    Thread t2 = new Thread(new MyThread());
    t2.start();
}

继承Thread和实现Runable的对比

开发中:优先选择实现Runnable接口的方式

方式三:实现Callable接口

  1. 创建一个实现Callable的实现类
  2. 实现call方法,将此线程需要执行的操作声明在call()
  3. 创建Callable接口实现类的对象
  4. 将此Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask的对象
  5. 将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
  6. 获取Callable中call()方法的返回值:get()返回值即为FutureTask构造器参数Callable实现类重写的call()的返回值。
// 实现Runnable
static class MyThread implements Callable<String> {
    @Override
    public String call() throws Exception {
        return Thread.currentThread().getName();
    }
}

public static void main(String[] args) throws Exception {
    // 创建FutureTask
    FutureTask<String> futureTask = new FutureTask<>(new MyThread());
    // 创建Thread
    Thread t1 = new Thread(futureTask);
    t1.start();
    // 阻塞获取返回值
    System.out.println(futureTask.get());
}

Thread类

java.lang.Thread

构造方法

常用方法

获取和设置优先级

getPriority():获取线程的优先级

setPriority(int p):设置线程的优先级

说明:高优先级的线程要抢占低优先级线程cpu的执行权。但是只是从概率上讲,高优先级的线程高概率的情况下被执行并不意味着只当高优先级的线程执行完以后,低优先级的线程才执行。

守护线程和用户线程

Thread userThread = new Thread("User Thread") {
    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName());
    }
};

Thread daemonThread = new Thread("Daemon Thread"){
    @Override
    public void run() {
        while (true){
            System.out.println(Thread.currentThread().getName());
        }
    }
};

userThread.start();
// 设置为守护线程,即使自己有死循环,也会随着所有用户线程的结束而退出
daemonThread.setDaemon(true);
daemonThread.start();

线程的生命周期

JDK中用Thread.State类定义了线程的几种状态

要想实现多线程,必须在主线程中创建新的线程对象。Java语言使用Thread类及其子类的对象来表示线程,在它的一个完整的生命周期中通常要经历如下的五种状态

说明: untitle.png

线程同步

通过同步机制,来解决线程的安全问题。

方式一:synchronized隐式锁

同步代码块

synchronized(同步监视器){     
    //需要被同步的代码  
}

同步方法

public synchronized void method(){}

可重入锁(递归锁)

所谓重入锁,指的是以线程为单位,当一个线程获取对象锁之后,这个线程内部可以再次获取本对象上的锁,而其他的线程是不可以的。

可以重复使用的锁,锁的创建只有一次,可以重复调用lock()unlock()

synchronizedReentrantLock都是可重入锁。

public class Demo {
    public static void main(String[] args) {
        Thread t = new Thread("A") {
            @Override
            public void run() {
                // 线程第一次获取锁
                synchronized (Demo.class){
                    for (int i = 0; i < 10000; i++) {
                        // 可重入,再次获取锁
                        synchronized (Demo.class){
                            System.out.println(Thread.currentThread().getName());
                        }
                    }
                }

            }
        };
        t.start();
    }
}

方式二:Lock显式锁

常用方法

ReentrantLock(可重入锁)

实现Lock接口,是一种可重入锁

public class Demo {
    public static void main(String[] args) {
        ReentrantLock lock = new ReentrantLock(true);
        Thread t = new Thread("A") {
            @Override
            public void run() {
                try {
                    // 线程第一次获取锁
                    lock.lock();
                    for (int i = 0; i < 10000; i++) {
                        // 可重入,再次获取锁
                        lock.lock();
                        System.out.println(Thread.currentThread().getName());

                    }
                }finally {
                    // 释放锁
                    lock.unlock();
                }
            }
        };
        t.start();
    }
}

ReentrantReadWriteLock(读写锁)

公平锁和非公平锁

公平锁的效率比非公平锁低

公平锁总是可以保证让所有线程中等待时间最长的线程先执行

new ReentrantLock(true)new ReentrantReadWriteLock(true)时,参数为true,创建的就是公平锁;不传参默认为false,就是非公平锁

synchronized与Lock的异同

死锁问题

不同的线程分别占用对方需要的同步资源不放弃,都在等待对方放弃自己需要的同步资源,就形成了线程的死锁

说明:

  1. 出现死锁后,不会出现异常,不会出现提示,只是所的线程都处于阻塞状态,无法继续
  2. 我们使用同步时,要避免出现死锁。

解决死锁

1、调整锁的顺序,避免可能出现的死锁

2、调整锁的范围,避免在一个同步代码块中使用另一个同步代码块

3、使用可重入锁

判断锁会不会释放

线程通信

使用synchronized强同步

注意:wait方法等待的线程,在哪等待,被唤醒后,就从哪里开始执行,可能会出现虚假唤醒的问题,所以建议wait循环使用在while循环中

使用Lock接口下的可重入锁

sleep()和wait()的异同

ThreadLocal类

可以在指定线程内存储数据,数据存储以后,只有指定线程可以得到存储数据(可以存放线程范围内的局部变量)

实际使用场景

在JavaWeb项目里面,我们做MyBatis的工具类的时候,为了可以满足多个线程中,各线程只关闭自己的SqlSession对象的需求,而不会产生线程安全的问题,所以,我们需要将SqlSession对象存入到ThreadLocal对象中

常用方法

CAS自旋锁

解决的问题

例如,一个int类型的变量,在高并发的情况下进行加减,通常会导致线程不安全,数值不准确的问题,例如

public class Demo {
    static int count = 0;
    public static void main(String[] args) {
        // 创建100个线程
        for (int i1 = 0; i1 < 100; i1++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        count++;
                    }
                }
            }).start();
        }
		// 确保其他线程都执行完成了,除了GC和主线程
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        // 结果打印出来的数据不一定是正确的10000,结果比10000少
        System.out.println(count);
    }
}

原因分析

由于加减操作例如count++count = count - 1并不是一个原子操作,在执行的时候分了三步:1、读取值;2、修改值;3、写入值。所以例如现在有两个线程A和B,原子性操作如下:

  1. 线程A:读取值为0
  2. 线程B:开始执行
  3. 线程B:读取值为0
  4. 线程B:修改读到的值为1
  5. 线程B:将值1写入变量count
  6. 线程A:开始执行
  7. 线程A:修改读到的值为1
  8. 线程A:将值1写入到变量count

所以造成了变量count的值经过两次加1,应该为2;但是实际上值为1

解决方式

1、使用synchronized同步锁的方式

缺点是:每次进行非原子操作前,都需要加锁,操作完解锁,效率低下

public class Demo {
    static int count = 0;
    public static void main(String[] args) {
        for (int i1 = 0; i1 < 100; i1++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        // 使用同步代码块将非原子操作同步
                        synchronized (Demo.class){
                            count++;
                        }
                    }
                }
            }).start();
        }
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(count);
    }
}

2、通过CAS自旋锁的方式进行

通过AtomicInteger,可以在线程提交的时候,用自己读到的数值,与现在变量中的数值进行比对。如果一致的话,就写入变量;如果数值已经发生改变的话,那么会进行自旋,也就是重新进行运算,再进行比较

public class Demo {
    static AtomicInteger count = new AtomicInteger(0);
    public static void main(String[] args) {
        for (int i1 = 0; i1 < 100; i1++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < 100; i++) {
                        //读取数据并自增,等同于count++
                        count.getAndIncrement();
                    }
                }
            }).start();
        }

        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(count);
    }
}
解决ABA问题

但是,自旋锁的方式,有可能会产生ABA的问题,例如现在有三个线程A、B、C,操作流程如下

  1. 线程A:读取值为0
  2. 线程B:开始执行
  3. 线程B:读取值为0
  4. 线程A:开始执行
  5. 线程A:+ 1;验证变量值为0,一致;写入变量;变量值为1
  6. 线程C:开始执行
  7. 线程C:读取值为1;- 1;验证变量为1,一致;写入变量,变量值为0
  8. 线程B:开始执行
  9. 线程B:+ 1;验证变量值和自己读取的都为0,一致,写入变量;变量值为1

这样看下来,虽然变量值和预期的值相同,但是中间变量其实已经修改过了,线程B比较的0已经不是自己当时读到的0了,在某些情况下,可能会出现问题

在CAS的思想上,利用版本戳的思想,从原始数据开始,给每次的数据都加上一个版本,每次对数据发生修改,版本也会进行迭代

具体的实现类,AtomicStampedReference,这个类在实例化时,第二个参数为版本号

public class Demo {
    // 第一个参数为保存的数据,第二个参数为版本戳
    static AtomicStampedReference<Integer> count = new AtomicStampedReference(0,0);
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    // 比较并且设置
                    // 1:比较的原始值,2:设置的新参数值
                    // 3、比较的原始版本戳,4、设置的新版本戳
                    count.compareAndSet(
                            count.getReference()
                            , count.getReference() + 1
                            , count.getStamp()
                            , count.getStamp() + 1
                    );
                }
            }
        }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100; i++) {
                    count.compareAndSet(
                            count.getReference()
                            , count.getReference() - 1
                            , count.getStamp()
                            , count.getStamp() + 1
                    );
                }
            }
        }).start();

        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(count.getReference());
        System.out.println(count.getStamp());
    }
}

线程池

线程池的执行顺序

1、用户提交任务,判断核心线程是否正在处理,如果核心线程有空闲,直接使用核心线程执行

2、如果核心线程没有空闲,判断队列是否满了,如果没有满,就把任务放进队列中

3、如果队列已经满了,那么判断线程池中线程+新任务线程是否大于最大线程数,如果大于,抛出异常,拒绝执行

4、如果小于等于最大线程数,创建新的线程,执行任务

image-20230427110025494

线程池的状态

状态 说明
RUNNING 允许提交并处理任务
SHUTDOWN 不允许提交新的任务,但是会处理完已提交的任务
STOP 不允许提交新的任务,也不会处理阻塞队列中未执行的任务,并设置正在执行的线程的中断标志位
TIDYING 所有任务执行完毕,池中工作的线程数为0,等待执行terminated()勾子方法
TERMINATED terminated()勾子方法执行完毕

SHUTDOWN 状态 和 STOP 状态 先会转变为 TIDYING 状态,最终都会变为 TERMINATED

线程池的创建

1、ThreadPoolExecutor(建议)

/* 创建线程池
 * 参数1:线程池创建后,核心线程数
 * 参数2:最大线程数
 * 参数3:核心线程外,新创建的线程空闲时,最大存活时间
 * 参数4:最大存活时间的单位
 * 参数5:用于存放任务的阻塞队列
 * 参数6:用于创建线程的线程工厂
 * 参数7:任务被拒绝后的策略
 */
ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
        2
        , 5
        , 60
        , TimeUnit.SECONDS
        , new ArrayBlockingQueue<>(5)
        , new ThreadPoolExecutor.AbortPolicy()
);
拒绝策略
策略 描述
ThreadPoolExecutor.AbortPolicy() 抛出RejectedExecutionException异常。默认策略
ThreadPoolExecutor.CallerRunsPolicy() 由向线程池提交任务的线程来执行该任务
ThreadPoolExecutor.DiscardPolicy() 抛弃当前的任务
ThreadPoolExecutor.DiscardOldestPolicy() 抛弃最旧的任务(最先提交而没有得到执行的任务)

线程池中,提交任务submit()与execute()之间的区别

2、Executors

可以使用Executors的静态方法,来创建线程池

创建方法 线程池 阻塞队列 使用场景
newSingleThreadExecutor() 单线程的线程池 LinkedBlockingQueue 适用于串行执行任务的场景,⼀个任务⼀个任务地执行
newFixedThreadPool(n) 固定数目线程的线程池 LinkedBlockingQueue 适用于处理CPU密集型的任务,适用执行长期的任务
newCachedThreadPool() 可缓存线程的线程池 SynchronousQueue 适用于并发执行大量短期的小任务
newScheduledThreadPool(n) 定时、周期执行的线程池 DelayedWorkQueue 周期性执行任务的场景,需要限制线程数量的场景
例如
public static void main(String[] args) {
    // 创建一个固定大小的线程池:
    ExecutorService es = Executors.newFixedThreadPool(4);
    Future<Integer> future = es.submit(new Task("a"));
    System.out.println(future);

    // 关闭线程池:
    es.shutdown();
}

阿里巴巴Java开发手册规定:线程池不允许使用Executors去创建,要求通过new ThreadPoolExecutor()的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。