首页 > 系统相关 >并发学习记录09:共享模型之内存

并发学习记录09:共享模型之内存

时间:2022-09-04 09:46:03浏览次数:65  
标签:Singleton INSTANCE 09 并发 线程 内存 单例 static public

Java内存模型

JMM指的是Java memory model,它定义了主存,工作内存等抽象概念,相当于做一个隔离层,将底层CPU寄存器,缓存,硬件内存,CPU指令优化提供的功能通过一个简单接口给使用者调用
JMM体现在以下几个方面:
原子性:保证指令不会受到线程上下文切换的影响
可见性:保证指令不会受到cpu缓存的影响
有序性:保证指令不会受cpu指令并行优化的影响

可见性

一个小例子:退不出的循环

@Slf4j(topic = "ch.Cha05JMMTest01")
public class Cha05JMMTest01 {
    static boolean run = true;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (run) {
//                log.debug("循环结束,run is {}",run);
            }
            log.debug("循环结束,run is {}",run);
        }, "t1");
        t.start();
        Thread.sleep(1000);
        run = false;
        log.debug("run is {}", run);
    }
}

可以发现虽然主线程把run改成了false,但是t线程仍然不会退出

原因分析如下:
1.初始状态,t线程刚开始从主内存读取了run的值到工作内存

2.因为t线程要频繁从主内存中读取run的值,JIT编译器会将run的值缓存到自己工作内存中的高速缓存中,减少对主存中run的访问,提高效率
这里主存相当于物理内存,工作内存相当于比物理内存更快的高速缓存,所以可以提高访问效率

3.1s之后,main线程修改了run的值并同步到了主存,而t是从自己工作内存中的高速缓存中读取这个变量的值,所以结果永远是旧值

这里就可以引入解决方法:volatile关键字
翻译过来是易变的,可以用来修饰成员变量和静态成员变量,可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存

可见性vs原子性

volatile保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程写,多个读线程读的情况。比较一下线程安全时举的例子,两个线程一个做i++,一个做i--,volatile只能保证每个线程看到的都是内存上的最新值,不能解决指令交错

注意

synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性,但缺点是synchronized属于重量级操作,性能相对更低。

两阶段终止设计模式

之前学过是利用Interrupt来打断运行或者sleep或者wait状态的线程,现在也可以使用停止标记来打断其他线程
方法一(interrupt)

@Slf4j(topic = "ch.Cha05TwpPhaseStop01")
public class Cha05TwpPhaseStop01 {
    public static void main(String[] args) throws InterruptedException {
        TPTInterrupt test = new TPTInterrupt();
        test.start();
        Thread.sleep(3000);
        log.debug("结束");
        test.stop();
    }
}

@Slf4j(topic = "ch.TPTInterrupt")
class TPTInterrupt {
    private Thread thread;

    public void start() {
        thread = new Thread(() -> {
            while (true) {
                Thread current = Thread.currentThread();
                if (current.isInterrupted()) {
                    log.debug("回收资源");
                    break;
                }
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    current.interrupt();
                    log.debug("睡眠状态被打断会清除打断标记");
                    e.printStackTrace();
                }
            }
        }, "t1");
        thread.start();
    }

    public void stop() {
        thread.interrupt();
    }
}

方法二:利用打断标记

//利用打断标记完成两阶段终止
@Slf4j(topic = "ch.Cha05TwpPhaseStop02")
public class Cha05TwpPhaseStop02 {
    public static void main(String[] args) throws InterruptedException {
        TPTVolatile test = new TPTVolatile();
        test.start();
        Thread.sleep(1000);
        test.stop();
    }
}

@Slf4j(topic = "ch.TPTVolatile")
class TPTVolatile {
    private Thread thread;
    private volatile boolean stop = false;

    public void start() {
        thread = new Thread(() -> {
            while (true) {
                if (stop) {
                    log.debug("回收资源");
                    break;
                }
                try {
                    Thread.sleep(5000);
                    log.debug("睡眠免得监控线程一直运行");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "监控线程");
        thread.start();
    }

    public void stop() {
        stop = true;
        log.debug("中断");
        thread.interrupt();
    }[]()
}

balking设计模式

定义

balking(犹豫)模式用在一个线程发现另一个线程或者本线程已经做了某一件相同的事,那么本线程就不需要再做了,可以直接结束返回

实现

一般被用来实现单例模式

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

有序性

JVM在会在不影响正确性的前提下,可以调整语句的执行顺序,比如:

static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...; 
j = ...; 

在上面的那段代码中,先执行i还是先执行j,对最终的结果不会产生影响,所以,上面代码真正执行时,可以是先做i的操作,也可以是先做j的操作,这种特性被称为指令重排,多线程下的指令重排会影响正确性。

附:指令重排的原理

现代处理器借鉴了流水线的思想,我们可以把指令的执行再划分为一个个更小的阶段,例如:每条指令都可以分为:取指令-指令译码-执行指令-内存访问-数据写回这5个阶段
取指令:instruction fetch
指令译码:instruction decode
执行指令:execute
内存访问:memory access
数据写回:register write back

我们在不改变程序结果的前提下,这些指令的各个阶段可以通过排序和组合实现指令间的并行,分工和分阶段可以很大程度上提高程序执行的效率

指令重排也是有前提的,那就是不能影响结果

// 可以重排的例子
int a = 10; // 指令1
int b = 20; // 指令2
System.out.println( a + b );
// 不能重排的例子
int a = 10; // 指令1
int b = a - 5; // 指令2

一个实例看多线程下的指令重排

int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
 if(ready) {
 r.r1 = num + num;
 } else {
 r.r1 = 1;
 }
}
// 线程2 执行此方法
public void actor2(I_Result r) { 
 num = 2;
 ready = true; 
}

I_result是一个对象,有一个属性r1用来保存结果,问,可能的结果有几种?
情况一:
线程1先执行,此时ready的值是false,所以进入else分支,结果为1
情况2:
线程2先执行到num = 2,但没执行ready = true,,所以还是进入else分支,结果为1
情况3:
线程2执行了ready = true,此时num = 2,所以r1这个时候是4
其实还有一种情况,线程2先执行ready = true,但是没有执行num = 2,因为这个时候发生了指令重排,这个时候,r1的值就是0.

这种现象就是指令重排,是JIT编译器在运行时的一些优化,这个现象需要大量测试才能发现,我们可以借助Java的压测工具jcstress。

解决方法:
使用volatile修饰变量可以禁用指令重排序。

//改之前
boolean ready = false;
//改之后
volatile boolean ready = false;

volatie的原理

volatile的底层实现原理是内存屏障:
对volatile变量的写指令之后会加入写屏障
对volatile变量的读指令之前会加入读屏障
volatile可以设置读写屏障

保证可见性

写屏障保证在该屏障之前的,任何对于共享变量的改动,都会同步到主存当中

public void actor2(I_Result r) {
 num = 2;
 ready = true; // ready 是 volatile 赋值带写屏障
 // 写屏障
}

意思就是ready = true是个对volatile变量进行的写操作,所以在这之前执行的指令,必须先ready = true之前,将num = 2执行并写入主存

读屏障保证的是在该屏障之后,对共享变量的读取,加载的是主存中的最新数据

如何保证有序性

写屏障会确保重排序时,不会将写屏障之前的代码排在写屏障之后

public void actor2(I_Result r) {
 num = 2;
 ready = true; // ready 是 volatile 赋值带写屏障
 // 写屏障
}

num=2就别想在ready = true之后执行

读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前

但是读写屏障是不能解决指令交错问题的,就是可以保证有序性和可见性,不能保证原子性

double checked locking问题

public final class Singleton {
            private Singleton() {
            }

            private static Singleton INSTANCE = null;

            public static Singleton getInstance() {
                if (INSTANCE == null) { // t2
                    // 首次访问会同步,而之后的使用没有 synchronized
                    synchronized (Singleton.class) {
                        if (INSTANCE == null) { // t1
                            INSTANCE = new Singleton();
                        }
                    }
                }
                return INSTANCE;
            }
        }

以上单例模式实现的特点
懒汉式:用到了才会实例化
首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
if (INSTANCE == null)这里使用了instance变量,是在同步块之外的,但在多线程环境下,是可能出现问题的

getInstance方法对应的字节码如下:

0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37
6: ldc #3 // class cn/itcast/n5/Singleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
17: new #3 // class cn/itcast/n5/Singleton
20: dup
21: invokespecial #4 // Method "<init>":()V
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn

其中,
17表示创建对象,将对象的引用入栈
20表示复制一份对象引用
21表示利用一个对象引用,调用构造方法
24表示利用一个对象引用,赋值给static INSTANCE

jvm可能会优化成:先执行24,再执行21,那么其他线程可能会在同步代码块之外拿到一个未初始化完毕的单例,这时候就可以用volatile来解决,保证JVM不会重排序造成执行上的问题

happens-before原则

happens-before规定了对共享变量的写操作对于其他线程的读操作可见,它是可见性和有序性的一套规则的总结抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其他线程对该共享变量的读可见

case1

static int x;
static Object m = new Object();
new Thread(()->{
 synchronized(m) {
 x = 10;
 }
},"t1").start();
new Thread(()->{
 synchronized(m) {
 System.out.println(x);
 }
},"t2").start();

线程对m解锁之前对于x变量的写,之后对m加锁的其他线程是可以读到这个写操作的,就是会输出10,10又是之前加锁m写入的x

case2:

volatile static int x;
new Thread(()->{
 x = 10;
},"t1").start();
new Thread(()->{
 System.out.println(x);
},"t2").start();

线程对于volatile变量的写操作,接下来,任何线程对于该volatile变量的读操作,都是可以读到的

case3:线程start前对变量的写,对该线程开始后对该变量的读可见

static int x;
x = 10;
new Thread(()->{
 System.out.println(x);
},"t2").start();

case4:

static int x;
Thread t1 = new Thread(()->{
 x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);

t1线程结束前对x的写操作,在t1线程结束后,其他线程都能读到这个写操作的结果

case5:

static int x;
public static void main(String[] args) {
 Thread t2 = new Thread(()->{
 while(true) {
 if(Thread.currentThread().isInterrupted()) {
 System.out.println(x);
 break;
 }
 }
 },"t2");
 t2.start();
 new Thread(()->{
 sleep(1);
 x = 10;
 t2.interrupt();
 },"t1").start();
 while(!t2.isInterrupted()) {
 Thread.yield();
 }
 System.out.println(x);
}

线程t1打断t2前对变量的写,对于其他线程得知t2被打断后(通过t2.interrupted 或 t2.isInterrupted来得知)的读操作,是可见的

case6:对变量默认值(0,false,null)的写操作,对于其他线程对于这个变量的读操作是可见的

case7:可见性具有传递性,如果x对y可见,y对z可见,那么x对z也会可见

volatile static int x;
static int y;
new Thread(()->{ 
 y = 10;
 x = 20;
},"t1").start();
new Thread(()->{
 // x=20 对 t2 可见, 同时 y=10 也对 t2 可见
 System.out.println(x); 
},"t2").start();

从上面程序可以看出,首先,y=10肯定是对于x=20是可见的,然后x是被volatile修饰的,x=20对t2是可见的,所以,y=10也对t2可见

一些思考题:

public class TestVolatile {
 volatile boolean initialized = false;
 void init() {
 if (initialized) { 
 return;
 } 
 doInit();
 initialized = true;
 }
 private void doInit() {
 }
}

希望doInit方法只被调用一次,上面的实现是否有问题?
有问题的,假设有两个线程t1,t2并行执行init方法,t1执行完if (initialized)时间片做出线程切换,t2也来执行if (initialized),那么他两对于条件的判断都是false,都会调用doInit,所以要把init放入同步代码块中,才能满足需求

单例模式的一些实现以及一些问题:

单例模式的第一种实现方式

    // 问题1:为什么加 final
// 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
    public final class Singleton implements Serializable {
        // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
        private Singleton() {
        }

        // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
        private static final Singleton INSTANCE = new Singleton();
        public static Singleton getInstance() {
            return INSTANCE;
        }

        public Object readResolve() {
            return INSTANCE;
        }
    }

问题1:为什么加final?
避免子类继承父类修改父类中的方法影响单例

问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例?
Java也可以通过反序列化创建一个对象,所以当你实现了序列化接口之后,我们可以通过反序列化来创建一个对象,从而影响单例,所以我们需要加上一个readResolve方法,当反序列化时,如果存在对象,就会返回你已经创建好的对象INSTANCE

问题3:为什么设置为私有? 是否能防止反射创建新的实例?
设置为私有可以避免其他类调用其构造方法创建对象破坏单例,但是也不能避免反射来创建新的实例

问题4:这样初始化是否能保证单例对象创建时的线程安全?
可以,由于静态成员变量的初始化是在jvm类加载时候完成,jvm会保证对象创建时的线程安全

单例模式的第二种实现方式

// 问题1:枚举单例是如何限制实例个数的
// 问题2:枚举单例在创建时是否有并发问题
// 问题3:枚举单例能否被反射破坏单例
// 问题4:枚举单例能否被反序列化破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式
enum Singleton { 
 INSTANCE; 
}

问题一:枚举单例是如何限制个数的?
INSTANCE在反编译之后就是枚举类的一个静态成员变量。

问题二:枚举单例在创建时是否有并发问题?
枚举单例是静态成员变量,它是在类加载的时候创建的,线程安全性是由JVM保证的。

问题三:枚举单例是否能用反射来破坏单例呢?
枚举单例是不能用反射来破坏单例的

问题四:枚举单例是否能被反序列化破坏单例
枚举虽然实现了序列化接口,但是考虑到了被反序列化破坏单例,所以是预防了这一情况

问题五:枚举单例属于懒汉式还是饿汉式
由于枚举变量实际上就是静态成员变量,在类加载时就会完成创建,所以其实枚举单例属于是饿汉式

单例模式的第三种实现方式

public final class Singleton {
 private Singleton() { }
 private static Singleton INSTANCE = null;
 // 分析这里的线程安全, 并说明有什么缺点
 public static synchronized Singleton getInstance() {
 if( INSTANCE != null ){
 return INSTANCE;
 } 
 INSTANCE = new Singleton();
 return INSTANCE;
 }
}

首先这个一定是线程安全的,因为这个synchronized是在static方法上加锁,就是对这个类对象加锁,锁的粒度是比较大的,多线程运行时,一定只有一个线程运行了getInstance方法,然后就会得到一个instance,之后才会解锁,之后的线程进入时就一定会拿到已经生成的INSTANCE,但是也存在缺点,那就是后面线程进入的时候,还会对这个类对象上锁。

单例模式的第四种实现

public final class Singleton {
 private Singleton() { }
 // 问题1:解释为什么要加 volatile ?
 private static volatile Singleton INSTANCE = null;
 
 // 问题2:对比实现3, 说出这样做的意义 
 public static Singleton getInstance() {
 if (INSTANCE != null) { 
 return INSTANCE;
 }
 synchronized (Singleton.class) { 
 // 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗
 if (INSTANCE != null) { // t2 
 return INSTANCE;
 }
 INSTANCE = new Singleton(); 
 return INSTANCE;
 } 
 }
}

问题1:解释为什么要加 volatile ?
synchronized代码块中的程序可能会出现指令重排序,如果重排序先做了赋值操作,再调用构造方法,那么其他线程有可能拿到没有调用构造方法的INSTANCE引用

问题2:对比实现3, 说出这样做的意义
第一次调用getInstance方法之后,之后调用getInstance方法会直接走判断然后返回instance,if判断没有加synchronized,所以性能会更高

问题3:为什么还要在这里加为空判断, 之前不是判断过了吗?
这是为了避免多个线程第一次去创建INSTANCE对象出现问题。

单例模式的第五种实现

public final class Singleton {
 private Singleton() { }
 // 问题1:属于懒汉式还是饿汉式
 private static class LazyHolder {
 static final Singleton INSTANCE = new Singleton();
 }
 // 问题2:在创建时是否有并发问题
 public static Singleton getInstance() {
 return LazyHolder.INSTANCE;
 }
}

问题1:属于懒汉式还是饿汉式?
是通过静态内部类的方式完成一个懒汉式的单例创建,类只会在第一次被用到时才会出发类加载操作,用到getInstance方法才会触发内部的类加载操作,没有执行类加载的话,静态内部类里面的静态变量也不会进行初始化操作

问题2:在创建时是否有并发问题?
类加载时对静态变量的赋值操作可以由JVM来保障线程安全性。

标签:Singleton,INSTANCE,09,并发,线程,内存,单例,static,public
From: https://www.cnblogs.com/wbstudy/p/16616084.html

相关文章

  • NC19885 [AHOI2009]CHESS 中国象棋
    题目链接题目题目描述在N行M列的棋盘上,放若干个炮可以是0个,使得没有任何一个炮可以攻击另一个炮。请问有多少种放置方法,中国像棋中炮的行走方式大家应该很清楚吧.一个......
  • 2022-09-03 维加斯隧道交易法
    1.第一步应该是ema55和ema144.169都是一个趋势的,比如ema55在144之上,但是具体是几分钟的级别,你需要再去寻找2.第二步就是找到几分钟的级别,这里你就看第一个绿色箭头,这第......
  • 学习 Go,一段旅程:标准库包和并发 #5
    学习Go,一段旅程:标准库包和并发#5大家好!很高兴再次见到你,我希望你做得很好。在本文中,我想分享我在学习Go编程语言方面的进展。本周,我了解了标准库包和并发。标准库包......
  • NC210981 mixup2混乱的奶牛
    题目链接题目题目描述混乱的奶牛[DonPiele,2007]FarmerJohn的N(4<=N<=16)头奶牛中的每一头都有一个唯一的编号\(S_i(1<=S_i<=25,000)\).奶牛为她们的......
  • 2022-2023-1 20221309 《计算机基础与程序设计》第一周学习总结
    2022-2023-120221309《计算机基础与程序设计》第一周学习总结作业信息班级:https://edu.cnblogs.com/campus/besti/2022-2023-1-CFAP作业要求:https://www.cnblogs.com/roc......
  • Linux下查看系统中占用内存和CPU最多的进程
    Linux下查看系统中占用内存和CPU最多的进程前一段时间参加面试,被问到一个场景题:就比如说我们发现一台Linux的服务器,它的一些系统资源,比如说CPU内存都涨得比较厉害的。......
  • 2022-09-03 第四组 王佳齐 学习笔记
    mvc是一种软件架构模式,把整个软件分为三层:Model、View、Controller获取数据,并且处理数据,返回给controller。User----user表其余的活都交给service操作数据库,执行s......
  • 提升linux下tcp服务器并发连接数限制
    提升linux下tcp服务器并发连接数限制-星辰大海ゞ-博客园 https://www.cnblogs.com/wjoyxt/p/6155672.html1、修改用户进程可打开文件数限制   在Linux平台上,无论......
  • 2022-09-03 第四小组 王星苹 学习笔记
    学习心得今天学习了mvc模式,这种写法很清晰明了,可以处理很多,可以给密码加密,主要就是加密很重要心情学习了MD5加密,感觉很神奇,这样数据库就算被盗,也登陆不上去,变得更安全......
  • 2022.09.02
    CodeforcesRound#818(Div.2)赛时:476+904+1176+930+0+0补题:476+904+1176+930+600+0A.MadokaandStrangeThoughts求满足\(a,b\leqn\)且\(\frac{lcm(a,b)}{g......