Java【多线程】(3)单例模式与线程安全
目录
1.前言
2.正文
2.1线程安全类
2.2杂谈(介绍几个概念)
2.2.1内存可见性
2.2.2指令重排序
2.2.3线程饥饿
1. 什么是线程饥饿?
2. 线程饥饿的常见原因
2.2.4区分wait和sleep
2.4单例模式
2.4.1饿汉模式
2.4.2懒汉模式
2.4.2指令重排序与线程安全
3.小结
1.前言
哈喽大家好吖,今天继续给大家分享Java中多线程的学习,今天主要先给上文做个收尾以及讲解单例模式,那么废话不多说,让我们开始吧。
2.正文
2.1线程安全类
先再重新回顾一个概念,到底如何判断会涉及线程安全问题,凡是该方法涉及到修改数据的操作,而且没有内部进行加锁操作,这样就会导致线程安全问题,那么接下来就来详细介绍线程安全类以及线程不安全类:
常见的线程安全类
集合框架
Vector
(同步方法)
Hashtable
(同步方法)
CopyOnWriteArrayList
(写时复制)
BlockingQueue
实现类(如ArrayBlockingQueue
,用于生产者-消费者模式)原子类
AtomicInteger
、AtomicLong
AtomicReference
、AtomicBoolean
工具类
String
(不涉及修改)
StringBuffer
(同步方法,线程安全版的StringBuilder
)
Collections.synchronizedList()
(包装非线程安全集合,如ArrayList
)
常见的线程不安全类
集合框架(集合类本身没有进行任何加锁限制)
ArrayList
、LinkedList
HashMap
、HashSet
StringBuilder
(非同步的字符序列操作)日期格式化类
SimpleDateFormat
(内部状态可变)其他工具类
Random
(共享种子可能导致竞争)上述集合中,有的虽然有synchronized,但不推荐使用,因为加锁这个事情,是有代价的,一旦在代码中使用了锁,意味着代码可能会因为锁的竞争,产生阻塞,这样程序执行的效率会大打折扣,一旦造成线程阻塞,从cpu中调度走,啥时候才能回来执行就未知了。
2.2杂谈(介绍几个概念)
2.2.1内存可见性
内存可见性也是造成线程安全问题的原因之一,我们先附上一个代码:
import java.util.Scanner;
public class test {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 0){
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
我们尝试运行一下,结果发现:
我们修改了flag值,结果发现t1线程没有像我们预期的会结束线程,一个线程读取,一个线程修改,修改线程的值,并没有被线程读到,这就是内存可见性问题。
讲一下为什么:
研究 JDK 的大佬们,就希望通过让编译器 & JVM 对程序员写的代码,自动的进行优化
本来写的代码是进行 xxxxx,编译器/VM 会在你原有逻辑不变的前提下, 对你的代码进行调整
使程序效率更高。编译器,虽然声称优化操作,是能够保证逻辑不变,尤其是在多线程的程序中,编译器的判断可能出现失误.可能导致编译器的优化,使优化后的逻辑,和优化前的逻辑出现细节上的偏差。
于是原因就显而易见了:
硬件架构影响
CPU缓存:每个线程可能在自己的CPU缓存中操作变量,而非直接读写主内存。
指令重排序:编译器和处理器可能优化指令顺序以提高性能,导致代码执行顺序与预期不一致。
Java内存模型(JMM)抽象
JMM规定所有变量存储在主内存,线程通过本地内存(缓存副本)操作变量。
线程间通信需通过主内存完成,本地内存更新若未同步到主内存,其他线程无法感知变化。
于是上述代码我们这样稍作修改就可以了:
import java.util.Scanner;
public class test {
public static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 0){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
结果如我们所愿。
那么我们不能一遇到内存可见性问题就选择sleep,那样会影响程序执行效率,所以说接下来我们引入一个关键字来解决这个问题:
volatile
是 Java 提供的一种轻量级的同步机制,主要解决 内存可见性 和 指令重排序 问题,但 不保证原子性。确保一个线程对volatile
变量的修改对其他线程立即可见。
问题根源:线程操作变量时可能使用本地缓存(如 CPU 缓存),而非直接读写主内存。
volatile
的解决:强制所有读写操作直接操作主内存,绕过线程本地缓存。
import java.util.Scanner;
public class test {
public volatile static int flag = 0;
public static void main(String[] args) {
Thread t1 = new Thread(()->{
while(flag == 0){
}
System.out.println("t1线程结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入flag值:");
flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
结束!
2.2.2指令重排序
还记得上文提到的volatile关键字吗,里面的讲解提到了一个指令重排序的问题,那么这个问题是什么意思呢?这里先简单提及下,讲到单例模式时会有详细讲解:
指令重排序是指在不改变单线程程序执行结果的前提下,编译器或处理器通过调整指令的执行顺序来优化性能。
编译器进行指令重排序的原因:
提高CPU利用率:减少流水线停顿,避免等待慢操作(如内存访问,上文提及)。
优化缓存效率:通过局部性原理提高缓存命中率。
并行执行指令:现代CPU的多级流水线和多核心架构需要指令级并行。
我们为什么之前没有遇到这个问题呢,因为我们在Java初阶的学习过程中大部分都是单线程环境下,只有在多线程环境下才会受到指令重排序的影响。
可见性问题:一个线程的修改对另一个线程不可见。
有序性问题:代码的实际执行顺序与预期不一致。
2.2.3线程饥饿
什么是线程饥饿呢?
1. 什么是线程饥饿?
线程饥饿指在多线程环境下,某个或某些线程长期无法获得所需的资源(如CPU时间片、锁、I/O等),导致其任务无法正常执行的现象。饥饿的线程可能永远等待,或执行进度远慢于其他线程。
关键特征:
非全局阻塞(其他线程仍正常运行)。
由资源分配策略或调度机制引起。
可能伴随优先级反转、资源竞争等问题。
2. 线程饥饿的常见原因
通过合理设计资源分配策略和使用同步工具,可有效减少线程饥饿的发生,保障多线程程序的稳定性和性能。
原因 说明 示例 高优先级线程抢占 高优先级线程始终优先获得CPU时间片,低优先级线程长期无法执行。 线程优先级设置不合理(如Java中 setPriority(10)
抢占低优先级线程)。非公平锁竞争 锁的获取策略不公平,某些线程始终竞争失败。 synchronized
关键字导致某些线程饥饿。资源独占 某个线程长期持有共享资源(如数据库连接、文件句柄),其他线程无法获取。 未合理释放资源(如忘记关闭锁或未用 try-finally
块)。任务调度策略缺陷 任务队列设计不合理(如固定顺序的任务分配)。 线程池使用无界队列或固定顺序提交任务。
2.2.4区分wait和sleep
在讲解单例模式前,再最后区分一下wait和sleep:
wait有等待时间,sleep也有等待时间,wait可以使用notify提前唤醒,sleep也可以使用Interrupt提前唤醒。
wait 和 sleep 最主要的区别,在于针对锁的操作.
- wait 必须要搭配锁.先加锁, 才能用 wait. sleep 不需要.
- 2)如果都是在 synchronized 内部使用, wait 会释放锁.sleep 不会释放锁~
2.4单例模式
单例模式是一种常用的软件设计模式,用于确保某个类只有一个实例,并且提供一个全局访问点。其中饿汉模式和懒汉模式是其中最经典的两种单例模式。
2.4.1饿汉模式
饿汉式单例在类加载时就创建实例,这种方式可以保证线程安全,但是实例的创建是立即进行的,可能会浪费资源。
class Singleton {
private static Singleton instance = new Singleton(100);
public static Singleton getInstance() {
return instance;
}
private Singleton() {
}
private Singleton(int n) {
}
}
public class test2 {
public static void main(String[] args) {
Singleton t1 = Singleton.getInstance();
Singleton t2 = Singleton.getInstance();
System.out.println(t1 == t2);
}
}
为了判断该代码仅创建了一个实例,我们创建t1和t2来判断是一个实例还是两个:
可以发现是一个实例。
2.4.2懒汉模式
懒和饿是相对的,一个是在程序一启动就创建好示例,另一个是尽可能晚的创建实例,以达到节省效率的目的。
懒汉式单例的特点是在需要的时候才创建实例,这种实现方式可以延迟实例的创建,节省资源。但是,如果多个线程同时访问getInstance方法,可能会导致多个实例的创建,因此需要进行同步处理。
class SingletonLazy{
public static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance == null){
instance = new SingletonLazy();
}
return instance;
}
private SingletonLazy(){
}
}
public class test3 {
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
// SingletonLazy s3 = new SingletonLazy();
}
}
仿照着饿汉模式,我们仿佛就把正确的代码写出来了,但这里要抛出一个很重要的问题:这样的代码是否是线程安全的呢?
2.4.2指令重排序与线程安全
第一个饿汉模式,在getinstance方法中只涉及到return的读操作,不涉及到线程安全问题。然而懒汉模式的getinstance方法
创建实例时可能涉及到多线程的修改操作,并且一个if语句加上与一个创建示例的语句,这样就违背了原子性的原则。
在多线程环境下,如果有多个线程同时调用
getInstance()
方法,可能会在检查instance == null
后,多个线程都进入if
块并创建新的实例。这是因为多个线程可能在同一时间检查到instance
为null
,从而都执行new SingletonLazy()
,导致创建多个实例。
所以我们就希望通过修改代码,使其避免上述问题。
但又有新的问题出现了,在多线程情况下,加锁会互相阻塞,影响执行效率,所以我们再进行修改:
此处最外层的if语句即为判断该实例是否已被创建,如果该实例以及被创建,就不需要进行获得锁操作,提升程序执行效率。
这样总会没问题了吧,不其实还有,有没有可能出现内存重排序问题呢,稳妥起见我们加上volatile
到这里就要呼应上文了,那指令重排序呢,现在就要讲了:
在创建实例时,要经过下面几个步骤:
- 申请内存空间
- 在空间上构造对象(初始化)
- 内存空间的首地址,赋值给引用变量
正常来说,这三个步骤按照 123这样的顺序来执行的,但是在指令重排序下,可能成为132 这样的顺序单线程环境下,123 还是 132 其实无所谓~~如果是13 2 这样的顺序执行,多线程环境下,可能会出现 bug !!
如果先进行1,3,那么很有可能出现尝试赋值时在对一个“未初始化的对象”进行操作,于是乎在这里,volatile也起到了解决指令重排序的问题。接下来就是正确的完整代码:
class SingletonLazy{ private static volatile SingletonLazy instance = null; private static Object locker = new Object(); public static SingletonLazy getInstance() { if (instance == null) { synchronized (locker) { if (instance == null) { instance = new SingletonLazy(); } } } return instance; } } public class test3 { public static void main(String[] args) { SingletonLazy s1 = SingletonLazy.getInstance(); SingletonLazy s2 = SingletonLazy.getInstance(); System.out.println(s1 == s2); // SingletonLazy s3 = new SingletonLazy(); } }
大功告成!
3.小结
今天的分享到这里就结束了,喜欢的小伙伴点点赞点点关注,你的支持就是对我最大的鼓励,大家加油!