JavaEE:多线程初阶(二)

一.多线程带来的风险----线程安全

什么是线程安全?

某些代码放在单线程的环境下可能是正确的,但是在多线程环境下就会产生问题,

下面让我用代码来带大家看一种情况

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() ->{
        for(int i = 0 ;i < 50000;i++){
            count++;
        }
    });   
    Thread t2 = new Thread(() ->{
        for(int i = 0 ;i < 50000;i++){
            count++;
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
}

上面这段代码,按照我们想象的情况来说,是不是count最后会为100000,但是进过多次重复执行,发现count的值并不是100000.这就是线程不安全

为什么会出现这种情况呢?

原因就是count++这个操作本质上对应的是三个CPU指令(在计算机是如何工作的这篇文章中有提及):

1.load操作:将内存中的数据读到CPU寄存器中

2.add操作:对寄存器的值进行加法操作,并存在寄存器中

3.save操作:将寄存器的值写会到内存中

正是因为count++是由这三个CPU指令所组成的,同时CPU对于线程的调度又是随机的,所以才会出现最终结果不是100000的情况

下面这几种情况,只有前两种是正确相加,剩下的都不能正确得到结果,但是因为CPU对于线程是随机调度的,很大概率不能保证100000次的count++全是正确的,这才会导致count的值不对

线程安全出现的原因:

1.线程调度是随机的------不可改变

2.多个线程改变一个变量------通过调整代码解决

3.操作不是原子性的------synchronized解决

4.内存可见性------volatile解决

5.指令重排序------volatile解决

本篇文章后面的内容都是为了解决由这些问题产生的不同的线程安全

二.synchronized关键字

对于上面那个count++的例子,那我们应该怎么解决呢?

我们可以通过synchronized这个关键字,将一堆代码块打包成一个整体,

这种方式就叫做锁操作.

通过加锁将代码打包成整体,这种操作设计两个核心操作,加锁和解锁

加锁是把若干个代码操作打包成一个整体代码块,但并不是让他成为一个原子操作

而是当锁处于加锁状态的时候,其他被加上同一个锁的代码块,只能等待锁解锁之后,才能执行锁里面的内容.

举个代码例子来说明

没加锁结果:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    Thread t1 = new Thread(() ->{       
            for(int i = 0 ;i < 50000;i++){
                count++;
            }      
    });
    Thread t2 = new Thread(() ->{
            for(int i = 0 ;i < 50000;i++){
                count++;
            }

    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
}

加锁结果:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    Object object = new Object();
    Thread t1 = new Thread(() ->{
        synchronized(object){
            for(int i = 0 ;i < 50000;i++){
                count++;
            }
        }
    });
    Thread t2 = new Thread(() ->{
        synchronized(object){
            for(int i = 0 ;i < 50000;i++){
                count++;
            }
        }
    });
    t1.start();
    t2.start();
    t1.join();
    t2.join();
    System.out.println(count);
}

这是因为加锁之后,其他的线程想要对同一个锁里面的代码执行的话,就需要等锁解锁之后.

就拿这个例子来说,假设线程1先调度,然后线程1拿到了object锁,此时就算CPU调度到了线程2,也会因为object锁在线程1那里,而产生阻塞,这种情况就叫所锁竞争.

1.synchronized()的特性

(1)互斥性

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

进入 synchronized 修饰的代码块,相当于 加锁

退出 synchronized 修饰的代码块,相当于 解锁

(2)可重入性

对同一个代码块进行多次加锁,是不会出现死锁的情况,就如同下面的代码

如果是不可重入锁的话,在第一个synchronized加锁成功之后,第二个synchronized就会加锁失败,然后一直卡在那里执行不了后面的代码

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

• 如果某个线程加锁的时候,发现锁已经被人占用,但是恰好占用的正是自己,那么仍然可以继续获取到锁,并让计数器自增.

• 解锁的时候计数器递减为 0 的时候,才真正释放锁.(才能被别的线程获取到)

2.synchronized的使用示例

(1)修饰代码块

对于这种代码,就相当于是用synchronized修饰代码块,

同时要记住,使用synchronized是禁止别的线程插队,而不是禁止线程调度

java 复制代码
        synchronized(object){
            for(int i = 0 ;i < 50000;i++){
                count++;
            }
        }

(2)修饰普通方法

这两种情况是等价的

(3)修饰静态方法

这两种情况是等价的

这里我们要重点注意,只有当两个代码块的锁对象是同一个的时候,才会产生锁竞争,如果不是同一个锁对象的话,是没有用的.

就像下面的代码,一个代码块用object,另一个用object2锁起来,这俩锁对象不是同一个,自然就不会产生锁竞争,也就不能解决线程安全问题

3.死锁问题

死锁是这样一种情形:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于线程被无限期地阻塞,因此程序不可能正常终止

举个简单的例子,就是车钥匙放在家里,家钥匙放在车里了,想要回家就得有车钥匙,但是想要车钥匙就得回家......

注意:加锁是由代价的,加锁会明显影响程序的效率,同时还会产生锁竞争,一但竞争就会产生堵塞,会影响程序效率,所以加锁要慎重!!!

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

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

施.

线程不安全:

ArrayList-------LinkedList-------HashMap-------TreeMap-------HashSet-------TreeSet-------StringBuilder

线程安全:

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

我们可以打开StringBuffer.class文件,看到这些操作都是带有synchronized关键字,所以这些方法都是线程安全的

同时还有String类,是不涉及修改操作,也是线程安全的

三.volatile关键字

让我们来举个简单的代码例子

java 复制代码
import java.util.Scanner;

public class Demo19 {
    static int flag ;
public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
        System.out.println("t1线程开始");
        while(flag == 0){
            
        }
        System.out.println("t1线程结束");
    });
    Thread t2 = new Thread(() -> {
        System.out.println("t2线程开始");
        Scanner scanner = new Scanner(System.in);
        flag = scanner.nextInt();
        System.out.println("t2线程结束");
        System.out.println("flag = " + flag);
    });
    t1.start();
    t2.start();
}
}

看这个例子

按照我们想象的,是不是应该两个线程同时启动,然后我在线程2中把flag的值改为1,然后线程1开始打印"t1线程结束"

但是结果确是将flag的值改为1之后,线程1仍然没有结束,这是为什么?

产生这个问题的原因就是内存可见性

我们在t2线程实实在在的改变了flag的值,但是t1线程却看不到flag的值发生变化

这就是编译器优化的结果.

对于编译器来说,在线程1中,有一个变量flag,会反复读取这个值(从内存中读取到寄存器,然后从寄存器中判断),判断是否等于0,

但是在判断了几亿次之后,发现这个值是不变的,所以编译器就把从内存中读取flag到编译器这一步给优化掉了,

这是因为这一步消耗的资源远远大于从寄存器中读取flag,

这就导致了之后我们在线程2中更改了flag的值到内存中,但是线程1却没有中内存中的读取到改变后的值,而是仍然从寄存器中读取改变前的flag值.

要解决这个问题,只需要在变量flag前面加上volatile关键字,

这个关键字会告诉编译器,这个变量会改变,让编译器每次读取这个变量都从内存中读取

改完之后重新运行代码,就可以发现已经可以按照我们预想的去运行了

四.wait和notify关键字

我们知道线程之间的调度是由CPU随机调度 的,但是有时候我们又需要线程之间有一个先后顺序

在之前,我们可以使用join()的方式等待一个线程执行完之后再执行另一个线程,这样的话就变成了两个线程串行了,虽然线程安全了,但是效率却下降了

现在如果有一个需求是在线程1运行到一半的时候停下来,等线程2运行完逻辑,然后再进行线程1的后续逻辑,这该怎么做呢?

这时候就需要用到wait和notify关键字了.

好比现在有线程1和线程2,线程1执行到一半,需要等线程2完成再执行

这时候就可以在线程1需要等线程2的地方添加wati,这时候线程1就会堵塞,直到线程2用notify通知线程1,这时候才会继续执行逻辑

观察下面代码,在合适的位置使用wait可以做到控制线程内部顺序的作用

java 复制代码
public class Demo20 {
public static void main(String[] args) {
    Object object = new Object();
    Thread t1 = new Thread(() -> {
        synchronized(object){
            System.out.println("t1线程前半部分逻辑,需要等待t2线程结束");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1线程后半部分各种逻辑");
        }
    });
    Thread t2 = new Thread(() -> {
        synchronized(object){
            System.out.println("t2线程各种逻辑");
            System.out.println("t2线程结束");
            object.notify();
        }
    });
    t1.start();
    t2.start();
}
}

1.wait的使用方法

这里我们需要注意:

1.wait 做的事情:

(1).使当前执行代码的线程进行等待.(把线程放到等待队列中)

(2).释放当前的锁

(3).满足一定条件时被唤醒,重新尝试获取这个锁.

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

2.wait 结束等待的条件:

(1).其他线程调用该对象的 notify 方法.

(2).wait 等待时间超时 (wait 方法提供一个带有 timeout 参数的版本,来指定等待时间).

可以看到增加了等待时间,即使没有notify,也会在等待时间过后继续执行后续逻辑

(3).其他线程调用该等待线程的 interrupted 方法,导致 wait 抛出 InterruptedException 异常.

2.notify的使用方法

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

方法 notify () 也要在同步方法或同步块中调用,该方法是用来通知那些可能等待该对象的对象锁的其它线程,对其发出通知 notify,并使它们重新获取该对象的对象锁。

如果有多个线程等待,则有线程调度器随机挑选出一个呈 wait 状态的线程。(并没有 "先来后到")

在 notify () 方法后,当前线程不会马上释放该对象锁,要等到执行 notify () 方法的线程将程序执行完,也就是退出同步代码块之后才会释放对象锁。

这里我们可以举一个唤醒多个wait的例子,看看效果

java 复制代码
public class Demo21 {
public static void main(String[] args) {
    Object object = new Object();
    Thread t1 = new Thread(() ->{
        synchronized(object){
            System.out.println("t1等待唤醒");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t1已经被唤醒");
        }
    });
    Thread t2 = new Thread(() ->{
        synchronized(object){
            System.out.println("t2等待唤醒");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("t2已经被唤醒");
    });
    Thread t3 = new Thread(() ->{
        synchronized(object){
            System.out.println("t3等待唤醒");
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("t3已经被唤醒");
        }
    });
    Thread t4 = new Thread(() ->{
        synchronized(object){
            System.out.println("t4随机唤醒一个线程");
            object.notify();
        }
    });
    t1.start();
    t2.start();
    t3.start();
    t4.start();
}
}

这个代码使用4个线程,3个线程等待t4唤醒,多次唤醒看看会有什么效果.

可以看出唤醒是随机的.但是如果有人尝试这个代码运行会发现大部分时候都是t1先被唤醒

这是因为Java的 wait() / notify() 机制使用 等待队列 ,这个队列是**FIFO(先进先出)**的

  1. t1最先启动 → 最先获取锁 → 最先调用wait() → 进入等待队列的第一个位置

  2. t2其次启动 → 进入等待队列的第二个位置

  3. t3最后启动 → 进入等待队列的第三个位置

  4. t4调用notify() → 唤醒等待队列中的 第一个线程 (t1)

所以大部分时候先会唤醒t1,我们还可以使用notifyAll()来唤醒全部线程,看看效果

可以看到使用notifyAll也是随机唤醒.

经典面试题sleep和wait 的区别:

1.wait 的设计就是为了提前唤醒的。超时时间,是 "后手"(B 计划)

sleep 的设计就是为了到时间唤醒。虽然也可以通过 Interrupt () 提前唤醒,这样的唤醒是会产生异常

2.wait 需要搭配锁来使用.wait 执行时会先释放锁.

sleep 不需要搭配锁使用。当把 sleep 放到 synchronized 内部时,不会释放锁.

五.多线程案例

1.单例模式

什么是单例模式?单例模式是校招常考的一种设计模式.

单例模式能够保证某一个类在程序中只创建一个实例,而不会出现有多个实例的情况.

什么又是设计模式呢?和下棋的棋谱类似,设计模式就是一些大佬总结出来的一些代码风格,用这个风格来写,可能代码不容易出错.

单例模式的视线方式有两种,分别是饿汉模式懒汉模式

(1).饿汉模式:在类加载的同时创建实例

将构造方法私有化,防止在外面创建实例

java 复制代码
class Instance{
    private  static Instance instance = new Instance();
    private Instance(){
        
    }
    public static Instance getInstance(){
        return instance;
    }
} 
public class Demo22 {
public static void main(String[] args) {
    Instance instance = Instance.getInstance();
}
}

此时如果还想在外面new一个实例,编译器就会报错

(2)懒汉模式:在类加载的时候不创建实例,第一次使用类的时候创建实例

类加载的时候先等于空,如果后续使用到的时候,判断是否为空,不为空再创建实例

java 复制代码
class Instance1{
    private static Instance1 Instance1 = null;
    private Instance1(){

    }
    public static Instance1 getInstance(){
        if(Instance1 == null){
            Instance1 = new Instance1();
        }
        return Instance1;
    }
}
public class Demo23 {
    public static void main(String[] args) {
        Instance1 instance1 = Instance1.getInstance();
    }
}

那我们看上述两种模式的单例代码,是否是线程安全的呢?

饿汉模式,只有读操作,没有写操作,很显然是线程安全的.

但是懒汉模式,很明显是线程不安全的!

我们考虑这个情况,如果有多个线程同时调用getInstance()这个方法,线程1在运行到判断是否为空这一行调度到线程2,线程2运行到判断是否为空这一行又调度走了...这样判断的结果都是空,然后再调度回原来的线程的时候就回new出多个实例!!!

所以懒汉模式是线程不安全的!

那么如何解决这个问题呢?很显然加锁就是一个解决的方法.

对于这种不是原子性操作导致的线程不安全问题,采用加锁,把代码块变成一个整体,可以有效解决线程不安全问题.

对if条件判断这个代码块加锁,就可以有效解决线程安全问题

但是与此同时,又有一个新的问题产生了.

我们知道加锁会对程序造成严重的效率问题.

在每一次使用getInstance这个方法的时候,都会进行一次加锁解锁操作.

但是我们又知道,只有在第一次Instance为空的时候,这个加锁才是有用的

后面每一次的加锁解锁操作都是多余操作,会严重影响效率,那应该怎么办呢?

这时候我们就可以在锁外面再加一个if条件判断,需要加锁的时候再加锁

(3)指令重排序导致的线程安全问题

解决完上述问题,你觉得这个代码就是线程安全的了吗?

其实还剩下最后一个问题,就是我们在开头提到的指令重排序引起的线程安全问题

我们先了解一下什么是指令重排序,以及指令重排序是怎么导致线程不安全的

我们将下面这个new代码可以分成三个CPU指令(对应的指令很多,简化为3个主要的)

java 复制代码
Instance1 = new Instance1();

1.申请内存空间

2.在内存空间上进行初始化操作(构造方法)

3.将内存地址保存到引用变量中

这三个步骤如果按照1,2,3这个顺序的话,其实是没有什么问题的.

但是编译器可能会对这三个步骤进行指令重排序(编译器优化的一种手段)

指令重排序,就是说编译器会在保证逻辑一致的前提下,改变代码的顺序,以提高效率.

在单线程的情况下,指令重排序是不会有什么影响的,但是多线程可能会出现问题

对于这个操作,1,2,3和1,3,2这两个顺序都可以起到相同的效果,所以说编译器就可能出现1,3,2这种顺序

看下图,如果在内存申请和保存地址这两步骤之后,线程1调度到线程2,然后判断是否为空

此时Instance1里面已经有地址了,只不过这个地址里面的属性什么的都是乱的,不对的

这样在判断第一个if条件的时候,就会直接跳过,然后直接返回Instance1,这样就会拿到一个没有初始化的对象

要解决这个问题,方法很简单,在Instance1前面加上volatile关键字就行

单例模式还有其他的实现方式,大家有兴趣也可以去了解一下

2.阻塞队列

队列想必大家都不陌生了,在之前的数据结构已经学过很多种队列了.

阻塞队列也是属于队列的一种,也是先进先出,相比于普通队列,有以下两个优点:

1.线程安全

2.带有阻塞功能

如果队列为空,尝试出队列就会触发阻塞,直到队列不空;

如果队列满了,尝试入队列,就会触发阻塞,直到队列不满

阻塞队列主要是用来协调多个线程之间的工作,如果这边线程比较忙,阻塞队列里面的线程就可以多等一会,这边线程不忙,阻塞队列里面的线程就可以出来工作

阻塞队列的优点:

1.减少资源竞争,提高效率

2.可以更好的做到模块之间的"解耦合"

在 Java 标准库中内置了阻塞队列。如果我们需要在一些程序中使用阻塞队列,直接使用标准库中的即可.

  • BlockingQueue 是一个接口。真正实现的类是 LinkedBlockingQueue.
  • put 方法用于阻塞式的入队列,take 用于阻塞式的出队列.
  • BlockingQueue 也有 offer,poll,peek 等方法,但是这些方法不带有阻塞特性.

如果阻塞队列满了就会阻塞在那行代码,直到队列不满

阻塞队列为空的时候也会阻塞在那,直到队列不为空

3.线程池

之前在学习String的时候,我们就提到了String常量池,是用来提前存一些常见的String变量,后续创建的时候就可以直接从常量池中拿就行.

线程诞生的目的就是为了解决进程过于繁重的问题,但是如果线程创建和销毁的次数太过频繁的话,就无法忽略创建的开销了.

这个时候就需要线程池了.

线程池也是同一个道理,提前把线程创建好,放到线程池(数据结构)中,后续创建线程的时候就可以直接从线程池中取.

语法:

在Java的官方文档中,我们可以看到线程池的使用方法有四种,其中最后一个的方法的变量最多,我们着重了解最后一个就行.

(1)corePoolSize和maxPoolSize

这两个变量分别表示核心线程数总线程数.

Java的线程池把线程分为两种,分别是核心线程和临时线程

所谓核心线程,就是Java线程池在创建的时候会先申请的一些线程,这些线程叫做核心线程,后续有什么任务都会给核心线程做.

如果任务忙不过来了,线程池会从系统中在申请一些其他线程过来帮忙,这些线程就叫临时线程

核心线程+临时线程=总线程数

所以这里的两个int值我们要填的就是核心线程数和总线程数

(2)keepAliveTime和nuit

从字面意思理解,这个单词表示保持存活时间

那么这是啥意思呢?

我们要知道核心线程是会一直存在在线程池里面的,但是临时线程不会,临时线程是有一个存活时间的.

当核心线程和任务队列都满了,忙不过来的时候才会创建临时线程,当核心线程可以忙过来了,临时线程就会空下来.

这时候,如果临时线程超过一定的时间没有工作,就会被销毁,这个时间就叫做keepAliveTime

至于后面的unit就是时间单位,常用取值:

(3)workQueue

这个就是任务队列,用来存放等待任务执行的队列,相当于是一种身份

线程池在创建的时候会先创建核心线程,然后给核心线程一些任务,这些任务就是从任务队列中取出来的.

这里用的任务队列就是我们上面提到的BlockingQueue阻塞队列

(4)threadFactory

这个是线程工厂,threadFactory这个类也是线程的工厂类.

由于在线程池中会产生很多线程,这些线程的初始化操作就是通过threadFactory来设置的.

(5)handler

从RejectExecutionHandler这个翻译我们就可以知道这个是拒绝策略

那么这个是干什么用的呢?

当我们的任务队列满的时候,来了新的任务队列了,这个时候该怎么办?

就需要通过拒绝策略来判断该怎么处置这个新来的任务.

通过Java官方文档我们可以找到这四种拒绝策略

(1)AbortPolicy

抛出异常,意思就是如果有新任务,不处理,直接抛异常

如果调用方不做处理,程序很可能崩溃

(2)CallerRunsPolicy

由提交任务的线程自己处理任务

(3)DiscardOldestPolicy

丢弃任务队列中排队最久的还没执行的任务,然后尝试执行新任务

(4)DiscardPolicy

丢弃这个新提交的任务,也不抛异常

很多人可能看到这里会觉得使用线程池怎么这么麻烦,但是Java官方给我们提供了一种简化版本的线程池的使用方式Executors,

有兴趣的可以去了解,不过更多时候还是使用参数更多的这种线程池,因为简化版本的默认参数太危险,不能自己调控

4.定时器

什么是定时器?

定时器也是软件开发中的一个重要组件。类似于一个 "闹钟". 达到一个设定的时间之后,就执行某个指定好的代码.

比如网络通信中,如果对方 500ms 内没有返回数据,则断开连接尝试重连.

比如一个 Map, 希望里面的某个 key 在 3s 之后过期 (自动删除).

标准库中的定时器:

标准库中提供了一个 Timer 类.Timer 类的核心方法为 schedule.

schedule 包含两个参数。第一个参数指定即将要执行的任务代码,第二个参数指定多长时间之后执行 (单位为毫秒).

下面这个代码执行之后就会再一秒后打印

java 复制代码
  public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("1秒后打印定时器");
            }
        },1000);
    }

六.线程的优点

1.创建一个新线程的代价要比创建一个新进程小得多

2.与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多

3.线程占用的资源要比进程少很多

4.能充分利用多处理器的可并行数量

5.在等待慢速 I/O 操作结束的同时,程序可执行其他的计算任务

6.计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现

7.I/O 密集型应用,为了提高性能,将 I/O 操作重叠。线程可以同时等待不同的 I/O 操作。

七.线程和进程的区别

1.进程是系统进行资源分配和调度的一个独立单位,线程是程序执行的最小单位。

2.进程有自己的内存地址空间,线程只独享指令流执行的必要资源,如寄存器和栈。

3.由于同一进程的各线程间共享内存和文件资源,可以不通过内核进行直接通信。

4.线程的创建、切换及终止效率更高


以上多线程初阶的内容就结束了,后面作者会给大家带来多线程进阶以及更多JAVAEE的内容

相关推荐
侠客行03177 小时前
Mybatis连接池实现及池化模式
java·mybatis·源码阅读
蛇皮划水怪7 小时前
深入浅出LangChain4J
java·langchain·llm
灰子学技术9 小时前
go response.Body.close()导致连接异常处理
开发语言·后端·golang
老毛肚9 小时前
MyBatis体系结构与工作原理 上篇
java·mybatis
风流倜傥唐伯虎10 小时前
Spring Boot Jar包生产级启停脚本
java·运维·spring boot
二十雨辰10 小时前
[python]-AI大模型
开发语言·人工智能·python
Yvonne爱编码10 小时前
JAVA数据结构 DAY6-栈和队列
java·开发语言·数据结构·python
Re.不晚10 小时前
JAVA进阶之路——无奖问答挑战1
java·开发语言
你这个代码我看不懂10 小时前
@ConditionalOnProperty不直接使用松绑定规则
java·开发语言
pas13610 小时前
41-parse的实现原理&有限状态机
开发语言·前端·javascript