多线程(从基础到进阶)

181 阅读20分钟

一. 什么是多线程?

要学习多线程,那么什么首先要知道什么是线程,在此之前我们已经有了一个进程的概念,什么是进程呢?当我们打开电脑的任务管理器,就有一个进程的字眼

image.png 这里的每个软件的运行其实就是一个进程,那么什么是线程呢? 举一个例子比如说QQ这个软件,他除了有聊天的功能外还有看视频的功能,还有发空间的功能,而这些功能都是在QQ这一个进程里的,那么这些功能就是线程。

再比如车间的工人,如果每个人只负责卸一台机器上的货物,而这货物传输的速度是十分钟一件,那么这卸完一件货物后就有十分钟的空闲时间,那么如果是给他同时负责多台机器这些时间就可以被利用起来。

image.png 总结来说:多线程就是可以让程序同时做多件事情,提高效率,并且有着非常广泛的应用场景。

二. 多线程的实现方式

方法1:继承Thread类

什么是Thread类?线程是操作系统中的概念,操作系统内核实现了线程这样的机制,且提供了API供用户使用,而Thread类是Java标准库中的,可以看做操作系统提供的API的进一步抽象和封装。 明白了什么是Thread类后,那么我们怎样写多线程代码呢?

分为三步

  • 自己定义一个类继承Thread
  • 重写Thread中的run方法(线程具体干什么事)
  • 创建对象,启动线程

image.png

这样线程就创建并且启动成功了 但是这看着好像和之前没有什么区别,让我们再看看下面这段代码

image.png 我们可以看到当我们再加上一个线程并且给他们取上名字后,线程的执行顺序开始飘忽不定了。 这里我们要引出两个概念

  • 并发:同一时刻,多个指令在单个CPU上交替执行
  • 并行:同一时刻,多个指令在多个CPU上同时执行 那么上面我们的线程1和线程2就属于并发执行。

方法2:实现Runnable接口

分为四步

  • 自己定义一个类实现Runnable接口
  • 重写里面的run方法
  • 创建一个自己的类的对象
  • 创建一个Thread类对象,开启线程

image.png

方法3:利用Callable接口和Future接口实现

这个方法的特点是可以获取到线程执行的结果,因为上面的两种方法重写的run方法都是没有返回值的,因此也就无法获取到多线程执行的结果,这种实现方式比较复杂 分为五步

  • 自己定义一个类实现Callable接口
  • 重写其中的call方法
  • 创建自己定义的Callable对象(表示要执行的任务---类似runnable)
  • 创建Future对象,但是由于他是一个接口无法直接创建故创建其实现类FutureTask的对象
  • 创建Thread类对象,启动线程

image.png

三. Thread的常用方法

1.getName方法

正如我们上面的例子所示,这个方法是可以获取到当前线程的名字 但是这里有一个细节,如果我们没有为线程设置名字那么他又会怎么样呢?

image.png 我们可以看到即使没有给线程设置名字,他也是有默认的名字的 格式为:Thread—(X)X从0开始 为什么是这个格式其实可以从源码中看到

image.png 再进入这个nextThreadNum()可以看到其实他就是一个简单的自增 image.png

2.setName方法

setName方法很简单就是给线程设置名字,但是其实Thread也提供了相应的构造方法进行设置名字,甚至是设置任务(runnable)。 方法为Thread(String name); Thread(Runnable runnable,String name);

image.png 但是我们看到直接赋值的话会报错,这是因为这个是Thread的构造方法是不能被继承的,需要使用super();去调用父类的构造。

image.png

3.currentThread方法

这个方法在上面也演示过了就是获取当前的线程,那么如果说我们没有启动线程那么他还能获取到吗?

image.png 启动后我们发现main,其实JVM虚拟机启动后,会自动启动多条线程,其中有一个就是main线程 他的主要功能就是执行main方法里的所有代码。

4.sleep方法

哪条线程执行到这个方法,那么哪条线程就会在这里“睡眠”对应的时间,他的参数单位是毫秒

image.png 我们在for循环中加入了sleep方法那么每执行一次,线程就会睡眠1秒钟

5.线程优先级setPriority、getPriority

在Java中线程执行是抢占式执行的,线程的执行顺序是随机的,但是线程执行也有优先级,我们可以用setPriority方法设置其优先级,优先级分为1-10,1最低,10最高,需要注意的是如果没有设置线程的优先级的话,那么他们都默认为5,main线程也一样。

image.png 我们可以看到,设置了线程1的优先级为1,而线程2的优先级为10,那么比较大的概率线程2先执行完1-100 要注意的是,只是大概率而不是一定,因为归根结底线程的执行顺序是随机的。

6.yield方法

这个方法又为出让线程或是礼让线程,当当前线程执行到这个方法的时候他会一定程度上的出让CPU的使用权

image.png 但是他也只是一定程度上出让,出让来但是没有完全出让,就想这个代码中线程2后面又连续执行了

7.join方法

这个方法也叫做插入线程 他可以让线程插入到当前线程之前。

image.png 我们可以看到t.join表示让t这个线程插入到当前线程(也就是main)线程之前,那么就会先打印完线程1的100,再打印main线程的0-9。

四. 线程的生命周期

image.png 线程有这五个时期,其中需要注意的是,就绪状态不能执行代码,而是要到运行阶段,并且sleep等阻塞方法时间到后,并不能直接运行下面代码,而是进入就绪状态继续抢CPU执行权。

五. 线程安全问题(重点开始!!!!)

什么是线程安全问题? 举一个例子,车站有三个窗口在卖同一趟车的车票100张,此时我们采用多线程的话,那么一个窗口就是一个线程。

image.png 我们发现3个窗口有在卖同一张票的情况,这显然不是我们想要的。 其实这一切都是因为线程执行是具有随机性的。

image.png 那么线程安全的概念就是:在多线程的环境下,代码运行结果是符合我们的预期的,即在单线程环境下应该的结果,那么这个程序就是线程安全的。

1. 线程不安全的原因

a.修改共享数据

正如上图中的ticket变量,3个线程都可以通过ticket++将其进行改变。

b.原子性问题

那么什么是原子性? 还是举一个重口味的例子。 现在有一个厕所,多个线程都想上厕所而且很急,如果此时线程1进去上厕所了(抢到了执行权),但是这个厕所可没有锁,那么线程2实在憋不住了,那么他就可以直接进去抢(抢夺了CPU执行权),这就是不具备原子性。 这里我们引出了一个非常重要的概念,也就是锁。 如果线程1进入厕所后将门锁住了,那么就不会发生线程2抢夺的情况了,那么就保证了其原子性。 在我们平时写的代码中,即使是一条Java语句也不见得是具备原子性的。 比如车站卖票例子中的 ticket++ 这条代码,他经历了3个部分才完成

  • 从内存把数据读进CPU
  • 将数据进行更新(++操作)
  • 把数据写回到CPU

c.内存可见性问题


import java.util.Scanner;
 
class Count{
    public int flag = 0;
}
public class demo2 {
    public static void main(String[] args) throws InterruptedException {
        Count count = new Count();
        Thread t1 = new Thread(()->{
           while (count.flag==0){
 
           }
            System.out.println("t1线程结束");
        });
 
        Thread t2 = new Thread(()->{
            Scanner scanner = new Scanner(System.in);
            System.out.println("请输入flag: ");
            count.flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
    }
}

这段代码想要的效果是,如果我们输入的flag值不是0的话,那么线程1就会结束

image.png 但是结果,我们输入了1,应该打印线程1结束,但是没有。 这是因为线程之间共享变量存在主内存。

当线程要读取或修改一个变量的时候,要将变量从主内存中到工作内存,再从工作内存中读取变量或者从工作内存中修改变量。

每个内存都有自己的工作内存,这就相当于一个共享的关系,所以此时修改了t2线程的变量,t1的工作内存不一定能够及时变化,这就导致了上述问题。 (这里的主内存和工作内存是Java的抽象术语,主内存其实就是内存,而工作内存是CPU寄存器和缓存)

d.指令重排序问题

此时有一段代码是这样的

  • 去快递站拿快递
  • 去教室写作业
  • 去快递站旁边的水果店买水果

若是在单线程的情况下,编译器会主动进行优化采用1-3-2的方式进行,可以少去一次快递站这就是指令重排序。

但是这不见得是一件好事,在多线程中代码执行复杂的比较高,而编译器进行优化可能会导致优化后的结果与预期的不符合。

2.解决线程不安全

同步代码块

既然我们产生线程不安全的原因是因为,线程调度的随机性,和修改共享数据,那么我们把共享数据锁起来不就可以解决了吗。

image.png 同步代码块有两个细节1.锁是默认打开的,有线程进去,锁自动关闭。2.里面的代码执行完毕,线程出来,锁自动打开

image.png 这样加锁后,当某个线程进入后,就不会发生另一个线程也同时进入的情况了,那么我们想要的效果也就出来了。

在书写同步代码块的时候有个细节:synchronized(锁对象)这个锁对象一定要是唯一的。 如果不是唯一的,那么假如线程1进去后,线程2想进去发现不是锁自己的锁,那么线程2一样可以进去,因为这个不是针对他的锁,这样就没有意义了。

最常见的写法就是当前类的类对象:类名.class(字节码文件对象)

synchronized关键字的特点

1.互斥

synchronized会起到互斥的效果,当某个线程进入到synchronized当中时,有其他线程也执行到同一个对象的synchronized就会发生阻塞等待。

进入synchronized修饰的代码块相当于“加锁” 退出synchronized修饰的代码块相当于“解锁”

synchronized用的锁是存在于Java对象头中的。

就比如厕所,要是可以使用的情况下,厕所上方会显示一个无人状态,而要是有人的状态,那么其他人就无法使用,只能排队。可以粗略的认为每个对象在内存存储时,有一块内存表示当前的锁状态,是“有人”,还是“无人”。

那么什么是阻塞等待呢:针对每一把锁,操作系统内部都维护了一个等待队列,当这个锁是加锁状态时,其他线程试图加锁就加不上了,只能阻塞等待,一直到之前的线程解锁后,由操作系统唤醒一个新的线程,再来获取到这个锁。

需要注意的是,上一个线程解锁后,下一个线程不是立刻就能获取到锁,而是需要操作系统进行唤醒操作,假如此时A线程解锁了,B线程比C线程先来,那么也不是B获取到锁,而是需要和C重新进行竞争。

2.刷新内存

何为刷新内存,正如我们之前所说的内存可见性问题,就是多个线程访问同一个变量时,一个线程修改该变量的值,其他线程能立即看到修改的值,而synchronized的工作过程是这样的

  • 获得互斥锁
  • 从主内存拷贝变量的最新副本到工作的内存
  • 执行代码
  • 将修改后共享变量的值刷新到主内存
  • 释放互斥锁

所以synchronized是可以保证内存可见性问题的。

3.可重入

什么是可重入锁?简单来说就是:同一个线程,重复申请自己所持有的锁对象,能够请求成功,而不会发生死锁。

锁的定义是,第二次加锁的时候就会阻塞等待,直到第一个锁被释放,才能获取到第二个锁,如果是一个线程在获取到当前对象锁的时候还没解锁又想获取一次,那么他是不是就需要阻塞等待自己释放这个锁,但是因为他在等待,所以他又无法进行解锁操作,这就导致了死锁。

Java中的synchronized是可重入锁,他就没有这样的问题。

在可重入锁内部包含“线程持有者”和“计数器”这两个信息,如果某个线程加锁的时候,发现锁被占用而且正好是自己占用的,那么仍然可以获取到锁,并且使计时器自增。解锁的时候计时器递减为0才真正的释放锁。

Volatile关键字

Volatile可以保证内存可见性。 Volatile就是一个修饰一个属性。

当代码写入volatile修饰的变量的时候

  • 改变线程工作内存中volatile修饰的变量的值
  • 将改变后的值从工作内存刷新到主内存中

当代码在读取volatile修饰的变量的时候

  • 从主内存中读取volatile变量的值到线程的工作内存中
  • 从工作内存中读取volatile变量的副本

解决可见性问题是带有volatile的变量,每次使用volatile变量前都必须先从主内存刷新最新的值,用于保证能看见其他线程对volatile变量所做的修改;每次修改volatile变量后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对volatile变量所做的修改。

总结着来说Volatile关键字的作用就是:使变量在多个线程中可见,方法是强制从公共堆栈中进行取值。

需要注意的是Volatile关键字不保证原子性问题。

六. 多线程案例

1.单例模式

那么什么是单例模式呢?单例模式是指在内存中创建且仅仅创建一次对象的设计模型,我们想想如果在程序中我们总是使用一个对象而且作用都相同时,每次使用这个对象就要创建一个实例,而频繁的创建实例会使得内存一直增加,而单例模式就可以创建一个对象,并且让需要调用的都共享这个对象。

image.png 单例模式中又可以分为懒汉模式和饿汉模式

  • 懒汉模式:什么时候要用到才创建实例。
  • 饿汉模式:在类加载的时候就创建好实例,等待使用。

a.懒汉模式

image.png

这个就是懒汉模式的代码,在需要的时候才创建实例,但是在多线程中这里有一个问题,如果两个线程同时判断if(singleton==null)的话,那不就创建多个实例了吗,所以我们需要将这段代码进行加锁操作。

image.png 这里我们进行了两次判空操作,故懒汉模式也被叫做 Double Check + Lock

再加上Volatile关键字进行修饰,保证每次线程读取的值是从内存中读取,从而保证内存可见性问题。

b.饿汉模式

image.png 饿汉模式朴实无华,就是在类加载的时候就创建出实例,等待调用就好。

2.生产者消费者模型

什么是生产者消费者模式,我们知道线程的执行是具有随机性的,而生产者消费者模式是一种十分经典的多线程协作的模式,也叫做等待唤醒机制。

我们想要的效果是如果有两个线程,那么需要让他们有规律的执行,也就是线程1执行一次,线程2执行一次,反复进行。

举一个例子,现在我们将生产者比作厨师,而消费者比作大胃王,而中间控制执行顺序的我们使用一个桌子

image.png 根据消费者等待和生产者等待我们可以将生产者和消费者需要做的步骤归纳为这几步,现在让我们用代码来实现看看

public class ThreadDemo {
    /**
     * 完成生产者消费者模型代码
     * 实现交替执行
     */
    public static void main(String[] args) {
        Cook c = new Cook();
        Foodie f = new Foodie();

        c.setName("厨师");
        f.setName("大胃王");

        c.start();
        f.start();
    }
}
public class Desk {
    /**
     * 控制生产者消费者执行顺序
     */

    //是否有食物
    public static int foodFlag = 0;

    //食物总个数
    public static int count = 10;

    //锁对象
    public static Object lock = new Object();
}
public class Cook extends Thread{
    @Override
    public void run() {
        while (true){
            synchronized (Desk.lock){
                if(Desk.count==0){
                    break;
                }else {
                    if(Desk.foodFlag==1){
                        try {
                            Desk.lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }else {
                        System.out.println("厨师做了一份食物!!!!!");
                        Desk.foodFlag = 1;
                        Desk.lock.notifyAll();
                    }
                }
            }
        }
    }
}
public class Foodie extends Thread{
    @Override
    public void run() {
        /**
         * 1.循环
         * 2.同步代码块
         * 3.判断共享数据是否到了末尾(到了末尾)
         * 4.判断共享数据是否到了末尾(没到末尾,执行核心代码)
         */

        while (true){
            synchronized (Desk.lock){
                if(Desk.count==0){
                    break;
                }else {

                     //判断桌子上有没有食物
                    if(Desk.foodFlag==0){
                        try {
                            //如果没有就等待
                            Desk.lock.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }else{
                        //如果有就开吃
                        Desk.count--;
                        System.out.println("大胃王正在吃食物,还能再吃"+Desk.count+"份!!!!");
                        //吃完之后唤醒厨师继续做
                        Desk.lock.notifyAll();
                        //修改桌子的状态
                        Desk.foodFlag = 0;

                    }
                }
            }
        }
    }
}

image.png

执行后,符合我们的需求交替执行,这就是生产者消费者模型。

3.阻塞队列实现等待唤醒机制

所谓阻塞队列,就是在上面例子中桌子被替换成了大桌子,可以自己规定上面可以放多少份食物,如果规定只能放一份,那么和上面的生产者消费者模型是一样的,那么这个大桌子就是阻塞队列

image.png

阻塞队列的实现类有两个:ArrayBlockQueue和LinkedBlockQueue,他们的接口都是BlockQueue,他们的区别在于ArrayBlockQueue是有界的,可以自定义范围,而LinkedBlockQueue是无界的,最大值为int,故这里我们使用ArrayBlockQueue。

import java.util.concurrent.ArrayBlockingQueue;

public class ThreadDemo {
    /**
     * 阻塞队列实现生产者消费者模型(等待唤醒机制)
     * 细节:
     *      生产者和消费者是使用同一个阻塞队列
     * @param args
     */
    public static void main(String[] args) {
        //1.创建阻塞队列的对象
        ArrayBlockingQueue<String> queue = new ArrayBlockingQueue(1);

        //2.创建线程的对象,把队列传过去
        Cook c = new Cook(queue);
        Foodie f = new Foodie(queue);

        //3.开启线程
        c.start();
        f.start();
    }
}
import java.util.concurrent.ArrayBlockingQueue;

public class Cook extends Thread{
    ArrayBlockingQueue<String> queue;

    public Cook(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }

    @Override
    public void run() {
        while (true){
            try {
                queue.put("食物");
                System.out.println("厨师放了一份食物!!!!!") ;
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
import java.util.concurrent.ArrayBlockingQueue;

public class Foodie extends Thread{

    ArrayBlockingQueue<String> queue;

    public Foodie(ArrayBlockingQueue<String> queue) {
        this.queue = queue;
    }
    @Override
    public void run() {
        while (true){
            try {
                String food = queue.take();
                System.out.println(food);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

这样就是阻塞队列实现的等待唤醒机制了。

七. 线程池

要知道什么是线程池,首先要先知道“池化思想”,比如线程池,字符串常量池,数据库连接池,他们都有一个共同的特性,就是提高资源的利用率。

如果我们没有使用线程池,那么线程应该经过这三步:

  • 手动创建线程对象
  • 执行任务
  • 执行完毕,释放线程对象

但是使用了线程池就是这样的效果

image.png

但是等待队列的作用是什么呢?这里我们看到我们只有三个任务正好可以对应三个线程,故不会有任务进行等待,那么要是这个时候来了四个任务,甚至五个任务呢?这个时候就需要进入到我们的等待队列进行等待。

image.png

这里需要注意的是等待队列的大小也是可以自己规定的。如果这个时候我们等待队列只能容纳两个任务,而这个时候来了六个任务怎么办?那么我们只能让线程池创建新的线程对象,然后让等待队列的任务去被这个新线程执行,从而让多的任务进入等待队列进行等待。

image.png

但是线程池的容量是有限的,如果当线程池无法创建线程的时候,等待队列也满了,那么我们只能不接受这个新来的任务。

说到这里我们可以总结一下线程池的好处:

  • 提高线程的利用率
  • 提高程序的响应速度
  • 便于统一管理线程
  • 可以控制最大并发数

接着让我们用代码实现一下线程池,Java提供了线程池的类(ThreadPoolExecutor(...))。

import java.util.concurrent.*;

public class Test {
    public static void main(String[] args) {
        ExecutorService executorService = new ThreadPoolExecutor(3,5,1L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(3), Executors.defaultThreadFactory(),new ThreadPoolExecutor.AbortPolicy());
        for (int i = 0; i < 5; i++) {
            executorService.execute(()->{
                System.out.println(Thread.currentThread().getName()+"====>在办理业务");
            });
        }
        executorService.shutdown();
    }
}

看这段代码,其中线程池ThreadPoolExecutor中的七个参数比较吓人,其实很简单我们将线程池比作去银行办理业务,银行中是不是时常有多个窗口,但是只开了其中的几个,那么剩下的没开的窗口其实也就是线程池中还可以新增的线程,而等待队列就是银行的等待区。

这个时候我们再来看七个参数1.核心线程数,就是银行默认开的窗口,也就是线程池一开始的线程数 2.最大线程数,也就是银行最多的窗口数,也就是线程池可开线程的容量 3.存活时间,就是在银行中要是我把窗口都打开了,但是人流量又不大,那么你就不如回家休息,就是把线程给关了,只剩默认核心线程(窗口) 4.时间单位(上面是1秒) 5.等待队列和其容量 6.线程工厂(默认) 7.拒绝策略(默认)

image.png 运行看看我们只要执行8个及其以内的任务都是可以的,因为我们定义最大线程数是5,等待队列的容量是3。

image.png 要是我们把任务数量增加到9个或以上,那么就会触发拒绝策略,就报错了。