✏️作者:银河罐头
📋系列专栏:JavaEE
🌲“种一棵树最好的时间是十年前,其次是现在”
目录
- volatile关键字
- wait 和 notify
- 多线程案例
- 单例模式
- 饿汉模式
- 懒汉模式
volatile关键字
volatile : 易变的,易失的
volatile和内存可见性是密切相关的。
class MyCounter{
public int flag = 0;
}
public class ThreadDemo{
public static void main(String[] args) {
MyCounter myCounter = new MyCounter();
Thread t1 = new Thread(()->{
while(myCounter.flag == 0){
//
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
这就和预期不符。
这个情况就是“内存可见性问题”,这也是一个线程不安全问题。
while(myCounter.flag == 0){
//
}
//这里使用汇编来理解,大概就是2步操作,
//1. load , 把内存中 flag 的值,读取到寄存器里
//2. cmp , 把寄存器的值和0进行比较,根据比较结果,决定下一步往哪个方向执行
这个循环执行速度极快,一秒钟执行百万次以上。
CPU针对寄存器的操作要比内存操作快很多,快3-4个数量级。计算机对于内存的操作,比硬盘快3-4个数量级。
循环执行这么多次,在t2真正修改之前, load得到的结果都是一样的,load和cmp相比速度慢很多。
由于load速度比cmp慢太多+反复load得到的结果是一样的。JVM就不再真正的重复load了,判定好像没人改flag值,干脆就只读取一次(编译器优化的一种方式)
内存可见性:一个线程针对一个变量进行读取操作,同时另一个线程针对这个变量进行修改。此时读到的值不一定是修改之后的值,这个读线程没有感知到变量的变化。(归根结底是编译器/JVM在多线程环境下优化时产生误判了)
此时就需要人为手动干预,可以给flag这个变量加上volatile关键字,意思是告诉编译器,这个变量是“易变的”,一定要每次都重新读取这个变量的内存内容,不确定什么时候就变了,不能再进行激进的优化。
volatile public int flag = 0;
volatile只能修饰变量。
volatile不能修饰方法里的变量(局部变量),局部变量只能在当前线程里面用,不能多线程之间同时读取/修改,天然的就规避了线程安全问题。
方法内部的变量在“栈”这样的空间上。每个线程都有自己的栈空间。即使是同一个方法,在多个线程中被调用,这里的局部变量也会处在不同的栈空间中,本质上是不同变量。
栈就是记录了方法之间的调用关系。
上述说的内存可见性 编译器优化问题,也不是始终会出现的(编译器可能会误判,也不是100%误判)
class MyCounter{
public int flag = 0;
}
public class ThreadDemo {
public static void main(String[] args) {
MyCounter myCounter = new MyCounter();
Thread t1 = new Thread(()->{
while(myCounter.flag == 0){
//休眠
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1 循环结束");
});
Thread t2 = new Thread(()->{
Scanner scanner = new Scanner(System.in);
System.out.println("请输入一个整数:");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
//去掉volatile,在循环体加休眠控制循环速度
//输出结果:
请输入一个整数:
1
t1 循环结束
编译器的优化,往往是无法预期的,所以稳妥点加上volatile
内存可见性问题,其他的一些资料,谈到 JMM,Java Memory Model , Java内存模型。
从 JMM的角度重新表述内存可见性的问题:
Java程序里,主内存,每个线程还有自己的工作内存( t1 的 t2 的工作内存不是一个东西)
t1 线程进行读取的时候,只是读取了工作内存的值
t2 线程进行修改的时候,先修改的工作内存的值,然后再把工作内存的内容同步到主内存中。
但是由于编译器优化,t1没有重新的从主内存同步数据到工作内存,读到的结果就是修改之前的结果。
主内存:main memory , 主存,也就是内存
工作内存: work memory => 工作存储区 (并非所说的内存,而是CPU上存储数据的单元,寄存器)
为啥Java这里不直接叫CPU寄存器,而搞了个“工作内存”这样的说法呢?
这里的工作内存,不一定只是CPU的寄存器,还可能包括CPU的缓存cache
CPU读取寄存器,速度比读取内存快多了,因此就会在CPU内部引入缓存 cache
寄存器存储空间小,读写速度快,贵;
中间搞了个 cache ,存储空间居中,读写速度居中,成本居中;
内存存储空间大,读写速度慢,便宜(相对于寄存器来说)
当CPU要读取一个内存数据时,可能是直接读内存,也可能是读cache,还可能是读寄存器。
引入cache之后,硬件结构就更复杂了。
工作内存(工作存储区):CPU寄存器+CPU的cache
一方面是为了表述简单,一方面也是为了避免涉及到硬件的细节和差异。Java里就使用工作内存这个词给他涵盖了
有的CPU可能没有cache,有的有;
有的CPU可能有1个cache,还可能多个;
现代CPU普遍是3级cache,L1,L2,L3…
volatile不保证原子性,原子性是靠synchronized来保证的。synchronized和volatile都能保证线程安全。
wait 和 notify
线程最大的问题就是抢占式执行,随即调度。
有一些办法可以控制线程之间的执行顺序,虽然线程在内核里的调度是随机的,但是可以通过一些api让线程主动阻塞,主动放弃CPU(为别的线程让路)
比如,t1 t2 两个线程,希望t1先干活,等把活干完了再让 t2 来干。就可以让 t2 先 wait(阻塞,主动放弃 CPU),等 t1 干的差不多了, 再notify通知t2,把t2唤醒,让 t2接着干。
那么上述场景,使用 join或sleep,行不行呢?
使用join必须要让t1彻底执行完,t2才能接着执行。如果希望t1执行50%再让t2接着执行,那么join做不到。
使用sleep,无法精确控制好休眠时间。
使用wait和notify可以更好的解决上述问题。
可以认为 wait和notify比join功能更强,涵盖了join的用途,但是wait和notify使用起来要比join麻烦
wait, notify, notifyAll 都是 Object 类的方法。所有类都默认继承Object类。所以所有类都有这3个方法。
wait进行阻塞。某个线程调用wait方法,就会进入阻塞(无论是通过哪个对象wait),此时就处在WAITING
InterruptedException
//这个异常,很多带有阻塞功能的方法都带
//这些方法都是可以被 interrupt 方法通过这个异常给唤醒的
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
object.wait();
//wait 不带任何参数,就是一个死等,一直等待,等到有其他线程唤醒它
}
}
锁的状态无非就是被加锁和被解锁
wait操作:
1.先释放锁
2.进行阻塞等待
3.收到通知之后,重新尝试获取锁,并且在获取锁之后继续向下执行
对于上述代码而言,这还没加锁怎么释放锁?!
所以,wait需要搭配synchronized使用
有关wait操作这里,举个形象的例子。
有一天张三去银行ATM机取钱,发现ATM机没钱了,只能等运钞机运钱过来。ATM也有个锁,只能等进去的人解锁,下一个人才能进去用。张三后面还排着很多人也要用ATM机。此时张三只能解锁(先释放锁)出来等(进行阻塞等待)运钞机。运钞机来了,操作之后ATM能取钱了(这里张三相当于收到通知了),然后张三再次进去ATM机取钱(重新尝试获取锁)。
public class ThreadDemo {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(()->{
System.out.println("t1: wait 之前");
try {
synchronized (object) {
object.wait();
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1: wait 之后");
});
Thread t2 = new Thread(()->{
System.out.println("t2: notify 之前");
synchronized (object) {
//notify要先获取到锁才能进行通知
object.notify();
}
System.out.println("t2: notify 之后");
});
t1.start();
Thread.sleep(500);
t2.start();
}
}
wait无参数,死等
wait带参数,指定了等待的最大时间
wait带参数版本和sleep有点类似,
wait是使用notify唤醒,sleep是用interrupt唤醒。
notify唤醒wait不会出现异常,而interrup唤醒sleep是出异常了
如果当前有多个线程在等待object对象,此时有一个线程object.notify(),此时是随机唤醒一个等待的线程
notifyAll和notify非常相似,多个线程wait的时候,notify随机唤醒一个,notifyAll唤醒所有线程,这些线程再一起竞争锁。
public class ThreadDemo {
//有 3 个线程,分别只打印 A , B , C ,控制 3 个线程固定按照 ABC 的顺序来打印
public static void main(String[] args) throws InterruptedException {
Object locker1 = new Object();
Object locker2 = new Object();
Thread t1 = new Thread(()->{
System.out.println("A");
synchronized (locker1){
locker1.notify();
}
});
Thread t2 = new Thread(()->{
synchronized (locker1){
try {
locker1.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("B");
synchronized (locker2){
locker2.notify();
}
});
Thread t3 = new Thread(()->{
synchronized (locker2){
try {
locker2.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("C");
});
t2.start();
t3.start();
Thread.sleep(100);
t1.start();
//保证t2和t3的wait都执行完了,再执行t1的notify
}
}
多线程案例
单例模式
单例模式是设计模式的一种。
单例模式,单个实例(对象)。
在有些场景中,有的特定的类,只能创建出一个实例,不应该创建多个实例。使用了单例模式后,此时想创建多个实例都难。
单例模式,就是针对上述的需求场景进行了个更强制的保证,通过巧用Java的现有语法,达成了某个类只能被创建出一个实例这样的效果(如果不小心创建出多个实例就会编译报错)
JDBC,DataSource 这样的类,其实就非常适合于用单例模式
在 Java 里实现单例模式的方式有很多种。
下面介绍最常见的 2 种
1)饿汉模式
2)懒汉模式
饿汉模式
// 饿汉模式 的 单例模式 实现
//此处保证 Singleton 这个类只能创建出一个实例
class Singleton{
//在此处,先把这个实例给创建出来了
private static Singleton instance = new Singleton();
//如果需要使用这个唯一实例,只能通过 Singleton.getInstance()方式进行获取
public static Singleton getInstance(){
return instance;
}
private Singleton(){}
//为了避免 Singleton 这个类不小心被复制出多个来
//把构造方法设置为 private , 这样在类外就无法通过 new 的方式创建出 Singleton 的实例
}
public class ThreadDemo{
public static void main(String[] args) {
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
//Singleton s3 = new Singleton();
System.out.println(s1 == s2);
}
}
Java代码里的每个类,都会在编译完成后得到.class文件
JVM运行时就会加载这个.class文件读取其中的二进制指令,并且在内存中构造出对应的类对象(形如Singleton.class)
由于类对象在一个Java 进程里,只是有唯一一份的,因此类对象内部的类属性也是唯一一份了
饿汉模式:一个饿了很久的人看到吃的就会很急切
private static Singleton instance = new Singleton();
//static 保证这个实例是唯一的,
//static 保证这个实例是在一定时机被创建出来
//(static属于这个实现方式中的灵魂角色)
//static 这个操作是让当前 instance 属性是类属性了,类属性是长在类对象上的,类对象又是唯一实例的(只是在类加载阶段被创建出一个实例)
运行一个 Java 程序,就需要让 Java 进程能够找到并读取对应的 .class 文件,就会读取文件内容并解析,构造成类对象…这一系列的过程操作,称为类加载。
要执行 Java 程序前提是要把类加载起来才行。
懒汉模式
class SingletonLazy{
private static SingletonLazy instance = null;
public static SingletonLazy getInstance(){
if(instance == null){
instance = new SingletonLazy();
//这个实例并非是类加载的时候创建了,而是真正第一次使用的时候去创建(如果不用就不创建)
}
return instance;
}
private SingletonLazy(){}
}
public class ThreadDemo{
public static void main(String[] args) {
SingletonLazy s1 = SingletonLazy.getInstance();
SingletonLazy s2 = SingletonLazy.getInstance();
System.out.println(s1 == s2);
}
}
上述写的饿汉模式和懒汉模式,如果在多线程环境调用getInstance,是否是线程安全的?
如何能让上述懒汉模式成为线程安全?
加锁
刚才线程不安全的原因是读,比较和写这 3 个操作不是原子的,这就导致 t2 读到的数据是 t1 还没来得及修改的(脏读)
这样写就会出现新的问题,每次getInstance都需要加锁,加锁操作是有开销的
仔细分析,其实只用在new对象之前加锁就可以,new完对象之后调用getInstance就只有比较和返回这 2 个读操作了。
此时还存在一个问题:内存可见性
如果很多线程都去getInstance , 这个时候是否有会被编译器优化的风险?(只有第一次读是读了内存,后续都是读寄存器/cache)
另外还有指令重排序的问题
instance = new SingletonLazy();
//这里拆分成 3 步
//1.申请内存空间
//2.调用构造方法,把这个内存空间初始化成一个合理的对象
//3.把内存空间的地址赋值给 instance 引用
正常情况下是按照1,2,3的顺序执行的。
但是编译器可能会进行指令重排序(为了提高程序效率,调整代码执行顺序)
1,2,3这个顺序可能会变化1 ,3,2 (如果是单线程,这里调整顺序没有本质区别)
但在多线程环境下会出问题
假设 t1 是按照 1,3,2 的顺序执行的,当 t1 执行到 1,3 之后执行 t2 之前被切出 CPU,t2来执行。当 t1 执行完 3 之后,instance 就非空了, t2 就直接返回了 instance 引用,并且还可能会尝试使用引用中的属性。但是 t1 的 2 还没完成,t2 拿到的是一个非法的对象。
这种情况轮到 volatile 上场了
volatile 有 2 个功能:解决内存可见性 + 禁止指令重排序。
最终调整之后的代码如下: