Java-多线程(基础)

目录

前言

一 . 进程和线程

1.1 进程

1.2 线程

1.3 进程和线程的关系

二 . 多线程的三种创建方式

2.1 Thread类

2.2 三种创建方式

2.3 方法解析

线程启动

中断线程

等待一个线程 join() 

三 . 线程的状态(了解)

四 . 线程安全问题(重点)

概念

线程不安全的例子

线程不安全的原因

内存可见性

指令重排序

五 . synchronized 关键字

5.1 synchronized 的特性

1) 互斥

2) 刷新内存

3) 可重入

5.2 . Java标准库中的线程安全类

六 . volatile 关键字

七 . wait和notify

7.1 wait()方法

7.2 notify()方法

7.3 notifyAll()方法

7.4 wait 和 sleep 的对比

八 . 多线程案例

8.1 单例模式 

8.2 阻塞队列

8.3 定时器

8.4 线程池

总结


前言

大家好,今天记录一下多线程相关的内容


一 . 进程和线程

1.1 进程

程序的一次执行过程

比如双击电脑中的QQ图标会出现一个弹窗,再双击一次,又会跳出一个弹窗,这两次程序的执行就是两个不同的进程

此时如果打开任务管理器,就可以看到每个进程都有一个PID用来唯一标识每一个进程 

1.2 线程

线程是指在操作系统中能够进行独立运行的基本单位,它是程序执行的一条路径,也被称为轻量级进程。线程可以同时运行多个,每个线程都有自己的执行流程、栈空间和局部变量等。线程可以共享进程的资源,如内存空间、文件句柄等,因此线程之间的切换开销比进程小,能够更高效地利用系统资源。线程的出现是为了解决多任务并发执行的问题

打开QQ相当于打开了一个进程,在QQ中我们可以打视频,聊天,语音电话这些不同的选择就可以看做是一条条不同的路径,即线程。

我们可以使用 jconsole.exe 程序去查看任意一个java程序中的线程

1.3 进程和线程的关系

线程是进程中的执行单元,一个进程可以包含多个线程,这些线程共享进程的资源和内存空间。线程之间可以并行执行,可以提高程序的性能和响应速度。线程之间可以通过共享内存来进行数据交换和协作。

总的来说,进程是程序的执行实例,而线程是进程中的执行单元。进程和线程的关系是一对多的关系,一个进程可以包含多个线程,而一个线程只能属于一个进程。在多核处理器上,多个线程可以并行执行,提高程序的并发性能。


1) 有了进程为什么还需要线程?

资源开销: 线程可以看做是一种轻量级的进程,线程的创建和释放的开销远远小于进程,线程共享进程的内存空间和系统资源,在多线程的情况下不需要每个线程分配独立的内存空间,减少了内存的开销

并发性能: 多线程可以实现更细粒度的并发控制,可以更有效地利用多核处理器的性能,提高程序的并发执行效率。

协作性:线程之间的通信和协作更加方便快捷,线程可以直接共享内存,通过共享内存进行数据交换和协作,不需要像进程那样使用进程间通信的方式来进行数据传输。

其实就是并发编程的需要

并发编程的重要性

  • 单核 CPU 的发展遇到了瓶颈. 要想提高算力, 就需要多核 CPU. 而并发编程能更充分利用多核 CPU 资源.
  • 有些任务场景需要 "等待 IO", 为了让等待 IO 的时间能够去做一些其他的工作, 也需要用到并发编程.

二 . 多线程的三种创建方式

2.1 Thread类

来看一下JDK官方文档的描述

其实也没什么可以看的,但是显得更加权威一点不是,哈哈

Thread 是java.lang包下的子类,编译器已经自动加载,不需要手动导包 我们学习这个类主要是学习它的一些常用方法
String setName()为当前线程设置名字
Static Thread currentThread()获取当前线程的对象
Static void sleep(long time)线程休眠指定时间,单位为ms
setPriority(int newPriority)为线程设置优先级
void setDaemon(boolean on)设置为守护线程

大家自行了解,没什么难度 

2.2 三种创建方式

1) 直接继承Thread,重写run方法

2) 实现Runable接口,重写call方法

不知道大家有没有发现Thread方法也继承了Runable方法

注 : 函数式接口是指只包含一个抽象方法的接口,它是Java 8中引入的新特性。函数式接口可以被用作Lambda表达式的类型,从而可以方便地实现函数式编程。

其实就是为了迎合lambda表达式,简化代码的编写

实现Runable接口,重写call方法

3) 实现Callable接口

本质上和Runable没有区别,唯一的区别是多线程执行之后可以返回结果

该结果用FutureTask类(简单理解为封装结果的类即可)来封装


2.3 方法解析

线程启动

中断线程

在多线程的程序中,一个线程的执行可能会需要另一个线程先去执行,线程之间的执行顺序我们必须要能自己控制,这个时候就需要我们在合适的时间合适的位置去中断某个线程。

目前常见的有以下两种方式:

  • 通过共享的标记来进行沟通
  • 调用 interrupt() 方法来通知

1) 通过共享的标记来通知

2) 调用 interrupt() 方法来通知

Thread 内部包含了一个 boolean 类型的变量作为线程是否被中断的标记.

方法说明
public void interrupt()

中断对象关联的线程,如果线程正在阻塞,则以异常方式通知,

否则设置标志位为true

public static boolean interrupted()判断当前线程的中断标志位是否设置,调用后清除标志位
public boolean isInterrupted()判断对象关联的线程的标志位是否设置,调用后不清除标志位

等待一个线程 join() 


三 . 线程的状态(了解)

public static void main(String[] args) {
    for(Thread.State state : Thread.State.values()){
        System.out.println(state);
    }
}

Java中的线程有以下几种状态:

  1. 新建状态(New):当线程对象被创建时,它处于新建状态。此时线程还没有开始执行,也没有分配任何系统资源。

  2. 运行状态(Runnable):当调用线程的start()方法后,线程进入就绪状态,等待系统分配CPU资源。一旦CPU资源被分配到该线程,线程就进入运行状态,开始执行run()方法中的代码。

  3. 阻塞状态(Blocked):当线程等待某些条件满足时,它可能会进入阻塞状态。例如,当线程调用了sleep()方法、wait()方法或者等待某个锁时,它会进入阻塞状态。在阻塞状态下,线程不会占用CPU资源,直到等待的条件被满足。

  4. 等待状态(Waiting):当线程等待某些条件满足时,它可能会进入等待状态。例如,当线程调用了wait()方法、join()方法或者park()方法时,它会进入等待状态。在等待状态下,线程不会占用CPU资源,直到等待的条件被满足。

  5. 超时等待状态(Timed Waiting):当线程等待某些条件满足时,它也可能会设置一个超时时间。例如,当线程调用了sleep()方法、wait()方法或者join()方法并设置了超时时间时,它会进入超时等待状态。在超时等待状态下,线程不会占用CPU资源,直到等待的条件被满足或者超时时间到达。

  6. 终止状态(Terminated):当线程执行完run()方法中的代码或者发生了未捕获的异常时,线程就进入终止状态。在终止状态下,线程不会占用CPU资源,也不能再次进入其他状态。

其中3,4,5 都可以划分为阻塞态,差别不大

四 . 线程安全问题(重点)

概念

如果多线程环境下代码运行的结果是符合我们预期的,即在单线程环境应该的结果,则说这个程序是线 程安全的。

线程不安全的例子

举个例子 两个线程分别针对count变量自增50000次

第一次执行结果

第二次执行结果

为什么会出现这种情况呢? 不仅结果不是100000次,而且每次执行的结果都不同,很明显,我们遇到了线程安全问题,这是一个很严重的问题!

其实解决办法很简单,但是我想给大家分析一下产生这个问题的原因

首先我们要明白count变量是被同一个进程中的所有线程共享的,那么同时针对count变量进行修改会不会出问题呢? 有人可能会说了,count++ 难道不是一步实现的吗?

其实说到这里,大家都该知道count++并不具备原子性,如果具备原子性,也就不会出现上面的一种情况了

一条 java 语句不一定是原子的,也不一定只是一条指令 比如刚才我们看到的 count++,其实是由三步操作组成的:

1. 从内存把数据读到 CPU (load)

2. 进行数据更新(add)

3. 把数据写回到 CPU(save)

如果大家了解过汇编,应该不难理解,一条java语句可能不止由一条指令组成


如果多线程程序执行上述代码,由于线程之间的调度顺序是随机的就会导致在某些调度顺序下,上述逻辑就会出现问题

正常情况

非正常情况(排列组合)

加了2次,但是内存中只加了一次,这就是根本原因

问题发现了,现在我们解决,如果大家了解过数据库的事务,相比不难想到,加个锁不就完了吗?

对,就是这么简单

synchronized是一个关键字,用于实现多线程之间的同步。它可以用来修饰方法或代码块,以确保在同一时刻只有一个线程可以访问被synchronized修饰的代码。

线程不安全的原因

主要有五个方面会产生线程不安全的问题

  • 操作系统对于线程的调度是随机的
  • 多个线程同时修改一个变量
  • 修改操作不是原子性
  • 内存可见性
  • 指令重排序

前三个不难理解,给大家说一下后面两个

内存可见性

可见性指, 一个线程对共享变量值的修改,能够及时地被其他线程看到

Java 内存模型 (JMM): Java虚拟机规范中定义了Java内存模型. 目的是屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并 发效果.

线程之间的共享变量存在 主内存 (Main Memory). 每一个线程都有自己的 "工作内存" (Working Memory) . 当线程要读取一个共享变量的时候, 会先把变量从主内存拷贝到工作内存, 再从工作内存读取数据. 当线程要修改一个共享变量的时候, 也会先修改工作内存中的副本, 再同步回主内存.

由于每个线程有自己的工作内存, 这些工作内存中的内容相当于同一个共享变量的 "副本". 此时修改线程 1 的工作内存中的值, 线程2 的工作内存不一定会及时变化 导致多个线程对同一个数据进行相同的操作


指令重排序

举个例子

一段代码是这样的:

1. 去前台取下 U 盘

2. 去教室写 10 分钟作业

3. 去前台取下快递

如果是在单线程情况下,JVM、CPU指令集会对其进行优化,比如,按 1->3->2的方式执行,也是没问题,可以少跑一次前台。这种叫做指令重排序

指令重排序是指现代处理器为了提高性能而对指令序列进行重新排序的一种优化技术。在计算机系统中,程序中的指令并不一定按照编写的顺序依次执行,处理器可能会对指令进行重新排序,以便更有效地利用处理器的资源,提高指令执行的吞吐量。

然而,指令重排序也可能引起一些问题。在多线程编程中,指令重排序可能导致线程间的安全问题,因为指令的执行顺序可能与程序员预期的不一致

简单来说就是处理器帮我们对代码进行了优化,但是优化结果并不符合程序员的预期,从而导致线程安全问题


五 . synchronized 关键字

5.1 synchronized 的特性

1) 互斥

synchronized 会起到互斥效果, 某个线程执行到某个对象的 synchronized 中时, 其他线程如果也执行到 同一个对象 synchronized 就会阻塞等待.

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

2) 刷新内存

synchronized 的工作过程:

  1. 获得互斥锁
  2. 从主内存拷贝变量的最新副本到工作的内存
  3. 执行代码
  4. 将更改后的共享变量的值刷新到主内存
  5. 释放互斥锁

3) 可重入

可重入锁是一种支持同一个线程多次获取同一把锁的锁机制。当一个线程持有某个锁时,可以再次获取该锁,而不会被自己持有的锁阻塞。这种特性使得可重入锁能够很好地支持递归函数、互斥访问共享资源以及防止死锁等场景。

把加锁想象成上厕所,滑稽出厕所了但是没有释放锁,想要再次进去,无法进入,这就是不可重入锁

如何实现可重入锁呢?

在可重入锁的内部, 包含了 "线程持有者" 和 "计数器" 两个信息.

  • 如果某个线程加锁的时候, 发现锁已经被人占用, 但是恰好占用的正是自己, 那么仍然可以继续获取 到锁, 并让计数器自增.
  • 解锁的时候计数器递减为 0 的时候, 才真正释放锁. (才能被别的线程获取到)

5.2 . Java标准库中的线程安全类

Java 标准库中很多都是线程不安全的. 这些类可能会涉及到多线程修改共享数据, 又没有任何加锁措施.

  • ArrayList
  • LinkedList
  • HashMap
  • TreeMap
  • HashSet
  • TreeSet
  • StringBuilder

但是还有一些是线程安全的. 使用了一些锁机制来控制.

  • Vector (不推荐使用)
  • HashTable (不推荐使用)
  • ConcurrentHashMap
  • StringBuffer

六 . volatile 关键字

volatile 修饰的变量, 能够保证 "内存可见性"

在Java中,volatile是一种关键字,用于修饰变量。使用volatile修饰的变量具有可见性和有序性两种特性。

可见性:当一个线程修改了volatile变量的值时,其他线程可以立即看到这个变量的最新值。这是因为volatile变量的值会被立即刷新到主内存中,而其他线程读取该变量时会从主内存中读取最新的值。

有序性:使用volatile修饰的变量可以保证其读写操作的顺序性。也就是说,如果一个线程先写入了一个volatile变量,然后另一个线程读取了这个变量,那么这个变量之前的所有操作都会被顺序地刷新到主内存中,而这个变量之后的所有操作也会被顺序地读取。

需要注意的是,虽然volatile变量具有可见性和有序性两种特性,但是它并不能保证原子性。也就是说,如果一个volatile变量被多个线程同时修改,那么仍然可能会发生线程安全等问题。如果需要保证原子性,可以使用synchronized关键字或者Lock接口等同步机制。

volatile保证内存可见性的核心: 强制读写内存

计算机运行的程序经常要访问数据,这些数据往往存在内存之中(定义一个变量,就是在内存中)

cpu使用这个变量的时候,就会把内存中的数据读出来,放到cpu的寄存器中,在参与运算(load)

cpu读写内存时间相对于cpu的其他操作是很慢的,因此为了改善这一状况,提高效率,编译器就大胆的对代码做出优化,把一些本来读取内存的操作,优化成读取寄存器.进而去提高效率

假如我有一个变量a,初始化为0, 使用线程1循环a读取10000次,如果a的值为非零,跳出循环

在线程1读取a的时候,线程2去修改变量a的值为非零,此时线程1应该会跳出循环,但是并没有,因为线程1并不知道a的值已经发生变化,因为a的值并没有从内存中更新到寄存器中


七 . wait和notify

由于线程之间是抢占式执行的, 因此线程之间执行的先后顺序难以预知.

但是实际开发中有时候我们希望合理的协调多个线程之间的执行先后顺序

7.1 wait()方法

wait 做的事情:

  • 使当前执行代码的线程进行等待. (把线程放到等待队列中)
  • 释放当前的锁 满足一定条件时被唤醒, 重新尝试获取这个锁.

wait 要搭配 synchronized 来使用. 脱离 synchronized 使用 wait 会直接抛出异常.

wait 结束等待的条件:

  • 其他线程调用该对象的 notify 方法.
  • wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本, 来指定等待时间).
  • 其他线程调用该等待线程的 interrupted 方法, 导致 wait 抛出 InterruptedException 异常.

7.2 notify()方法

notify 方法是唤醒等待的线程.

  • 方法notify()也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的 其它线程,对其发出通知notify,并使它们重新获取该对象的对象锁。
  • 如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")
  • 在notify()方法后,当前线程不会马上释放该对象锁,要等到执行notify()方法的线程将程序执行 完,也就是退出同步代码块之后才会释放对象锁。

7.3 notifyAll()方法

notifyAll()方法是Java中Object类的一个方法,用于唤醒等待在该对象上的所有线程。当一个线程调用了对象的notifyAll()方法时,所有在该对象上等待的线程都会被唤醒,然后它们会重新竞争对象的锁。

notifyAll()方法通常与wait()方法配合使用,用于实现线程间的协作

需要注意的是,notifyAll()方法会唤醒所有等待的线程,而不是只唤醒一个。因此,在使用notifyAll()方法时,需要谨慎考虑是否需要唤醒所有等待的线程。

7.4 wait 和 sleep 的对比

1. wait 需要搭配 synchronized 使用. sleep 不需要.

2. wait 是 Object 的方法 sleep 是 Thread 的静态方法.


八 . 多线程案例

8.1 单例模式 

单例模式是比较爱考的模式,约定某个类,只能有唯一一个对象.通过编码技巧,让编译器强制检查

这一点在很多场景上都需要. 比如 JDBC 中的 DataSource 实例就只需要一个

单例模式具体的实现方式, 分成 "饿汉" 和 "懒汉" 两种

饿汉模式

类加载的时候创建实例

class Singleton {
    private static Singleton instance = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() {
        return instance;
    }
}

懒汉模式 - 单线程

类加载的时候不创建实例. 第一次使用的时候才创建实例. 

class Singleton {
    private static Singleton instance = null;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

上面的代码单线程状态下是安全的,但是多线程是不安全的,需要改进一下

class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public synchronized static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
       }
        return instance;
   }
}

 上面的代码基于锁竞争和指令重排序两方面考虑是存在问题的

在多线程的情况下多个线程针对一个对象进行加锁,不可避免的会出现锁竞争的问题

为了解决这个问题,代码优化如下

class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        /*
        * 第一个if: 用来判断是否需要进行加锁
        * */
        if (instance == null) {
            synchronized (Singleton.class) {
                /*
                * 用来判断是否需要new对象
                * 假如a,b两个线程都通过了第一个if
                * a抢到了锁并加上了,此时b就会阻塞等待
                * 等到a出来并且释放了锁此时instance已然不为空,即使b进来也不会再new一个对象
                * */
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

另一个问题是指令重排序

优化之后

使用volatile关键字修饰的变量,对它的写操作会立即刷新到主内存中,而对它的读操作会从主内存中读取最新的值。这样可以保证在多线程环境下,对volatile变量的读写操作是原子的,不会出现线程安全问题。同时,volatile关键字还会禁止指令重排序,确保变量的读写操作按照代码中的顺序执行,从而避免了由于指令重排序引起的线程安全问题

8.2 阻塞队列

阻塞队列实现

通过 "循环队列" 的方式来实现. 使用 synchronized 进行加锁控制.

put 插入元素的时候, 判定如果队列满了, 就进行 wait. (注意, 要在循环中进行 wait. 被唤醒时不一 定队列就不满了, 因为同时可能是唤醒了多个线程).

take 取出元素的时候, 判定如果队列为空, 就进行 wait. (也是循环 wait)

/*
 * 阻塞队列
 * */
public class MyBlockingQueue<V> {
    private final String[] items = new String[100];
    private volatile int head = 0;
    private volatile int tail = 0;
    private volatile int usedSize;

    public void put(String item) throws InterruptedException {
        synchronized (this) {
            while (usedSize >= items.length) {
                // 队列满 阻塞
                this.wait();
            }
            items[tail] = item;
            tail++;
            if (tail >= items.length) {
                tail = 0;
            }
            usedSize++;
            this.notify();
        }
    }

    public String take() throws InterruptedException {
        synchronized (this) {
            while (usedSize == 0) {
                // 队列空 阻塞
                this.wait();
            }
            String ret = items[head];
            head++;
            if (head >= items.length) {
                head = 0;
            }
            usedSize--;
            this.notify();
            return ret;
        }
    }

}

8.3 定时器

定时器是一种实际开发中非常常用的组件. 比如网络通信中, 如果对方 500ms 内没有返回数据, 则断开连接尝试重连. 比如一个 Map, 希望里面的某个 key 在 3s 之后过期(自动删除). 类似于这样的场景就需要用到定时器.

标准库中的定时器

  • 标准库中提供了一个 Timer 类. Timer 类的核心方法为 schedule .
  • schedule 包含两个参数. 第一个参数指定即将要执行的任务代码, 第二个参数指定多长时间之后 执行 (单位为毫秒).
Timer timer = new Timer();
timer.schedule(new TimerTask() {
    @Override
    public void run() {
        System.out.println("hello");
   }
}, 3000);

实现定时器

  • 定时器的构成:

    为啥要带优先级呢? 因为阻塞队列中的任务都有各自的执行时刻 (delay). 最先执行的任务一定是 delay 最小的. 使用带 优先级的队列就可以高效的把这个 delay 最小的任务找出来.

  • 队列中的每个元素是一个 Task 对象.
  • Task 中带有一个时间属性, 队首元素就是即将要执行的任务
  • 同时有一个 worker 线程一直扫描队首元素, 看队首元素是否需要执行
/*
 * 定时器
 * */
public class Timer {
    // 使用一个数据结构,保存要执行的任务,使用优先级队列
    PriorityQueue<MyTimerTask> priorityQueue = new PriorityQueue<>();

    Object locker = new Object();


    // 定义构造任务方法
    public void schedule(Runnable runnable, long delay) {
        synchronized (locker) {
            priorityQueue.add(new MyTimerTask(runnable, delay));
            locker.notify();
        }
    }

    public Timer() {
        // 定义扫描线程
        Thread t = new Thread(() -> {
            while (true) {
                try {
                    synchronized (locker) {
                        while (priorityQueue.isEmpty()) {
                            locker.wait();
                        }
                        MyTimerTask task = priorityQueue.peek();
                        Long curTime = System.currentTimeMillis();

                        if(curTime >= task.getTime()){
                            // 执行任务
                            task.getRunnable().run();

                            // 任务执行完,出队列
                            priorityQueue.poll();
                        }else{
                            locker.wait(task.getTime() - curTime);
                        }
                    }

                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        });
        t.start();
    }
}

8.4 线程池

线程池是一种用于管理和复用线程的机制,它可以在程序启动时创建一定数量的线程,并在需要时重用这些线程,从而减少线程创建和销毁的开销,提高程序的性能和响应速度。

标准库中的线程池

  • 使用 Executors.newFixedThreadPool(10) 能创建出固定包含 10 个线程的线程池.
  • 返回值类型为 ExecutorService
  • 通过 ExecutorService.submit 可以注册一个任务到线程池中
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(new Runnable() {
    @Override
    public void run() {
        System.out.println("hello");
   }
});

Executors 创建线程池的几种方式

  • newFixedThreadPool: 创建固定线程数的线程池
  • newCachedThreadPool: 创建线程数目动态增长的线程池.
  • newSingleThreadExecutor: 创建只包含单个线程的线程池.
  • newScheduledThreadPool: 设定 延迟时间后执行命令,或者定期执行命令. 是进阶版的 Timer.

Executors 本质上是 ThreadPoolExecutor 类的封装

ThreadPoolExecutor的构造方法

handler(拒绝方式/拒绝策略): 在阻塞队列满了之后继续添加任务,该如何应对?

  • AbortPolicy: 用于被拒绝任务的处理程序,它将抛出 RejectedExecutionException.
  • CallerRunsPolicy: 谁是添加这个任务的线程,谁就去执行该任务
  • DiscardOldestPolicy: 用于被拒绝任务的处理程序,它放弃最旧的未处理请求,然后重试 execute;如果执行程序已关闭,则会丢弃该任务。
  • DiscardPolicy: 用于被拒绝任务的处理程序,默认情况下它将丢弃被拒绝的任务。

总结

这篇博客主要讲了一些多线程的基础部分,大家好好理解,下一篇博客见