在本系列内容中我们会对JUC做一个系统的学习,本片将会介绍JUC的内存部分
我们会分为以下几部分进行介绍:
我们首先来介绍一下Java内存模型:
JMM的主要作用如下:
JMM主要体现在三个方面:
这一小节我们来介绍可见性
首先我们根据一段代码来体验什么是可视性:
- // 我们首先设置一个run运行条件设置为true,在线程t运行1s之后,我们在主线程修改run为false希望停下t线程
-
- static boolean run = true;
- public static void main(String[] args) throws InterruptedException {
- Thread t = new Thread(()->{
- while(run){
- // ....
- }
- });
- t.start();
- sleep(1);
- run = false;
- }
-
- // 线程t不会如预想的停下来!
我们进行简单的分析:
我们提供两种可见性的解决方法:
- // 它可以用来修饰成员变量和静态成员变量
- // 他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
-
- // 我们首先设置一个run运行条件设置为true,在线程t运行1s之后,我们在主线程修改run为false希望停下t线程
-
- static volatile boolean run = true;
- public static void main(String[] args) throws InterruptedException {
- Thread t = new Thread(()->{
- while(run){
- // ....
- }
- });
- t.start();
- sleep(1);
- run = false;
- }
-
- // 这时程序会停止!
- // 我们对线程内容进行加锁处理,synchronized内部会自动封装对其主存进行查找
-
- static Object obj = new Object();
- static boolean run = true;
- public static void main(String[] args) throws InterruptedException {
- Thread t = new Thread(()->{
- synchronized(obj){
- while(run){
- // ....
- }
- }
- });
- t.start();
- sleep(1);
- run = false;
- }
-
- // 这时程序会停止!
我们对volatile和synchronized两种方法进行简单对比:
我们在这里介绍一下为什么synchronized能进行可见性问题解决:
关于volatile的讲解我们会在后面单独列出
我们在这一小节来修改之前讲解的两阶段终止模式
我们重新回顾一下两阶段终止模式:
我们给出具体模式图:
我们首先介绍错误的一些方法:
然后我们再来回想一下我们之前所使用的方法:
- /*主函数*/
-
- public class Main(){
- public static void main(String[] args){
- TPTInterrupt t = new TPTInterrupt();
- t.start();
- Thread.sleep(3500);
- log.debug("stop");
- t.stop();
- }
- }
-
- /*模式函数(采用interrupt以及isInterrupt判断来决定是否打断进程)*/
-
- 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(1000);
- log.debug("将结果保存");
- } catch (InterruptedException e) {
- //打断sleep线程会清除打断标记,所以要添加标记
- current.interrupt();
- }
- // 执行监控操作
- }
- },"监控线程");
- thread.start();
- }
-
- public void stop() {
- thread.interrupt();
- }
- }
-
- /*结果展示*/
-
- 11:49:42.915 c.TwoPhaseTermination [监控线程] - 将结果保存
- 11:49:43.919 c.TwoPhaseTermination [监控线程] - 将结果保存
- 11:49:44.919 c.TwoPhaseTermination [监控线程] - 将结果保存
- 11:49:45.413 c.TestTwoPhaseTermination [main] - stop
- 11:49:45.413 c.TwoPhaseTermination [监控线程] - 料理后事
但是在我们学习了Volatile方法之后,我们可以修改上述代码:
- /*主函数*/
-
- public class Main(){
- public static void main(String[] args){
- TPTVolatile t = new TPTVolatile();
- t.start();
- Thread.sleep(3500);
- log.debug("stop");
- t.stop();
- }
- }
-
- /*修改后的模式函数*/
-
- class TPTVolatile {
-
- private Thread thread;
-
- // 停止标记用 volatile 是为了保证该变量在多个线程之间的可见性
- private volatile boolean stop = false;
-
- public void start(){
- thread = new Thread(() -> {
- while(true) {
- Thread current = Thread.currentThread();
- // 我们采用stop变量来判断是否结束进程
- if(stop) {
- log.debug("料理后事");
- break;
- }
- try {
- Thread.sleep(1000);
- log.debug("将结果保存");
- } catch (InterruptedException e) {
- }
- // 执行监控操作
- }
- },"监控线程");
- thread.start();
- }
- public void stop() {
- // 调用后,修改stop,让主线程停止操作
- stop = true;
- //让线程立即停止而不是等待sleep结束
- thread.interrupt();
- }
- }
-
- /*结果展示*/
- 11:54:52.003 c.TPTVolatile [监控线程] - 将结果保存
- 11:54:53.006 c.TPTVolatile [监控线程] - 将结果保存
- 11:54:54.007 c.TPTVolatile [监控线程] - 将结果保存
- 11:54:54.502 c.TestTwoPhaseTermination [main] - stop
- 11:54:54.502 c.TPTVolatile [监控线程] - 料理后事
我们在这一小节来讲解新的模式Balking
我们首先来简单介绍一下模式:
该模式的用途如下:
我们直接给出该模式的模板代码:
- public class MonitorService {
-
- // 用来表示是否已经有线程已经在执行启动了
- private volatile boolean starting;
-
- // 测试模板的方法
- public void start() {
- log.info("尝试启动监控线程...");
- // 首先我们需要先锁住内部信息,防止多线程时导致混乱(因为内部存在数据变动,可能无法导致原子性)
- synchronized (this) {
- // 我们先来判断是否该方法已执行,若已执行直接返回即可
- if (starting) {
- return;
- }
- // 若未执行,实施方法,并将参数设置为true使后续线程无法使用
- starting = true;
- }
- //其实synchronized外面还可以再套一层if,或者改为if(!starting),if框后直接return
- // 真正启动监控线程...
- }
- }
我们再给出一套单例创建对象的案例:
- 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;
- }
- }
我们在这一小节来讲解新的原理指令级并行
在正式进入原理讲解之前我们需要明白几个概念:
Clock Cycle Time
主频的概念大家接触的比较多,而 CPU 的 Clock Cycle Time(时钟周期时间),等于主频的倒数,意思是 CPU 能 够识别的最小时间单位
CPI
有的指令需要更多的时钟周期时间,所以引出了 CPI (Cycles Per Instruction)指令平均时钟周期数
IPC
IPC(Instruction Per Clock Cycle) 即 CPI 的倒数,表示每个时钟周期能够运行的指令数
CPU 执行时间
程序的 CPU 执行时间,即我们前面提到的 user + system 时间,可以用下面的公式来表示
程序 CPU 执行时间 = 指令数 * CPI * Clock Cycle Time
我们要讲的指令级并行实际上就是概念化的流水线操作:
取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
的处理 器,就可以称之为五级指令流水线。我们给出流水线操作图:
我们首先来介绍一下指令重排:
我们给出一个指令重排的例子:
- // 可以重排的例子
- int a = 10; // 指令1
- int b = 20; // 指令2
- System.out.println( a + b );
-
- // 不能重排的例子
- int a = 10; // 指令1
- int b = a - 5; // 指令2
其实指令重排优化就是由流水线操作来演变过来的:
取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回
这 5 个阶段我们给出一张指令级并排操作的展示图:
这一小节我们来介绍可见性
我们同样采用一个问题来引出有序性概念:
- /*代码展示*/
-
- 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;
- }
-
- /*结果展示(多次执行)*/
-
- // 我们会发现1,4都是按照正常逻辑执行,但是0原本来说不应该出现
- *** INTERESTING tests
- Some interesting behaviors observed. This is for the plain curiosity.
-
- 2 matching test results.
- [OK] test.ConcurrencyTest
- (JVM args: [-XX:-TieredCompilation])
- Observed state Occurrences Expectation Interpretation
- 0 1,729 ACCEPTABLE_INTERESTING !!!!
- 1 42,617,915 ACCEPTABLE ok
- 4 5,146,627 ACCEPTABLE ok
-
- [OK] test.ConcurrencyTest
- (JVM args: [])
- Observed state Occurrences Expectation Interpretation
- 0 1,652 ACCEPTABLE_INTERESTING !!!!
- 1 46,460,657 ACCEPTABLE ok
- 4 4,571,072 ACCEPTABLE ok
-
- /*结果分析*/
-
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
-
- 情况2:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
-
- 情况3:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)
-
- // 由于指令重排,num = 2;ready = true; 都不会导致该线程出现错误,所以可能会将 ready = true操作先进行执行!
- 特殊情况:线程2 执行 ready = true,切换到线程1,进入 if 分支,相加为 0,再切回线程2 执行 num = 2
我们同样可以采用两种方法进行解决:
- /*代码展示*/
-
- public class ConcurrencyTest {
-
- int num = 0;
-
- // 在加上volatile之后,会导致ready写操作以及写之前的操作不会发生指令重排
- // 在加上volatile之后,会导致ready读操作以及读之后的操作不会发生指令重排
- volatile boolean ready = false;
-
- public void actor1(I_Result r) {
- if(ready) {
- r.r1 = num + num;
- } else {
- r.r1 = 1;
- }
- }
-
- public void actor2(I_Result r) {
- num = 2;
- ready = true;
- }
- }
- /*代码展示*/
-
- public class ConcurrencyTest {
-
- int num = 0;
-
- boolean ready = false;
-
- public void actor1(I_Result r) {
- if(ready) {
- r.r1 = num + num;
- } else {
- r.r1 = 1;
- }
- }
-
- public void actor2(I_Result r) {
- // synchronized会控制指令顺序不发生改变
- synchronized(this){
- num = 2;
- ready = true;
- }
- }
- }
我们将在这一小节彻底解决volatile原理层面的问题
我们首先需要知道volatile是依靠什么完成操作的:
volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
对 volatile 变量的写指令后会加入写屏障
对 volatile 变量的读指令前会加入读屏障
首先我们来查看写屏障:
- // 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
-
- public void actor2(I_Result r) {
- num = 2;
- ready = true; // ready 是 volatile 赋值带写屏障
- // 写屏障
- }
然后我们来查看读屏障:
- // 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
-
- public void actor1(I_Result r) {
- // 读屏障
- // ready 是 volatile 读取值带读屏障
- if(ready) {
- r.r1 = num + num;
- } else {
- r.r1 = 1;
- }
- }
我们给出一张读写屏障的流程图:
我们同样先来展示写屏障:
- // 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
-
- public void actor2(I_Result r) {
- num = 2;
- ready = true; // ready 是 volatile 赋值带写屏障
- // 写屏障
- }
我们再来查看读屏障:
- // 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
-
- public void actor1(I_Result r) {
- // 读屏障
- // ready 是 volatile 读取值带读屏障
- if(ready) {
- r.r1 = num + num;
- } else {
- r.r1 = 1;
- }
- }
我们同样给出一张流程图:
但是我们需要注意的是:
volatile不能解决指令交错:
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
而有序性的保证也只是保证了本线程内相关代码不被重排序
我们针对注意点给出一张解释图:
我们来进行一个简单的问题解析:
- // 以著名的 double-checked locking 单例模式为例
-
- public final class Singleton {
-
- private Singleton() { }
-
- // 这里创建了唯一一个单例对象
- private static Singleton INSTANCE = null;
-
- public static Singleton getInstance() {
- // 我们首先对INSTANCE进行检测
- // (这一步是为了保证我们只有在创造对象的那一步需要涉及到锁,对于后面的获取方法不要涉及锁,加快速率)
- if(INSTANCE == null) {
- // 这一步是为了保证多线程同时进入时,防止由于线程指令参杂而导致两次赋值
- synchronized(Singleton.class) {
- // 我们需要再次进行判断,因为当t1线程执行到锁中时,可能有t2进程也通过了第一个if判断,
- // 如果不添加这一步,就会导致t2进程进入后直接再次赋值,导致两次赋值
- if (INSTANCE == null) {
- // 在不出现任何问题下,我们对唯一对象进行创建
- INSTANCE = new Singleton();
- }
- }
- }
- // 如果已有对象,我们直接调用即可
- return INSTANCE;
- }
- }
以上的实现特点是:
我们查看上述代码,会感觉所有内容都毫无疏漏,但是如果是多线程情况下,出现线程的指令重排就会导致错误产生:
- /*源代码展示*/
-
- 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 "
" :()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
-
- /*指令重排问题*/
- 在正常情况下,我们会按照17,20,21,24的顺序执行
- 但是如果发生指令重排问题,导致21,24交换位置,就会导致先进行赋值,再去创建对象
- 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
- 如果同时我们的t2线程去运行,就会导致直接调用那个未初始化完毕的单例,会导致很多功能失效!
我们针对上述重排问题给出一张流程图:
其实解决方法很简单:
我们给出具体解决方法:
- /*代码展示*/
-
- public final class Singleton {
- private Singleton() { }
- private static volatile Singleton INSTANCE = null;
- public static Singleton getInstance() {
- // 实例没创建,才会进入内部的 synchronized代码块
- if (INSTANCE == null) {
- synchronized (Singleton.class) { // t2
- // 也许有其它线程已经创建实例,所以再判断一次
- if (INSTANCE == null) { // t1
- INSTANCE = new Singleton();
- }
- }
- }
- return INSTANCE;
- }
- }
-
- /*字节码展示(带有屏障解释)*/
-
- // -------------------------------------> 加入对 INSTANCE 变量的读屏障
- 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 "
" :()V - 24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
- // -------------------------------------> 加入对 INSTANCE 变量的写屏障
- 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
-
- /*具体解析*/
-
- 如上面的注释内容所示,读写 volatile 变量时会加入内存屏障(Memory Barrier(Memory Fence)),保证下面 两点:
- - 可见性
- - 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- - 而读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- - 有序性
- - 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- - 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- - 更底层是读写变量时使用 lock 指令来多核 CPU 之间的可见性与有序性
-
- 更简单来说:
- - 由于写屏障的前面不会发生指令重排,我们的21和24顺序不会颠倒,我们的赋值一定是已经完成初始化的赋值!
我们来介绍一下happens-before:
我们来进行总结:
- static int x;
-
- x = 10;
-
- new Thread(()->{
- System.out.println(x);
- },"t2").start();
- volatile static int x;
-
- new Thread(()->{
- x = 10;
- },"t1").start();
-
- new Thread(()->{
- System.out.println(x);
- },"t2").start();
- 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();
- static int x;
-
- Thread t1 = new Thread(()->{
- x = 10;
- },"t1");
-
- t1.start();
- t1.join();
- System.out.println(x);
- 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(()->{
- try {
- Thread.sleep(1000);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- x = 10;
- t2.interrupt();
- },"t1").start();
-
- while(!t2.isInterrupted()) {
- Thread.yield();
- }
- System.out.println(x);
- }
对变量默认值(0,false,null)的写,对其它线程对该变量的读可见
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z
此外我们还需要注意几点:
happens-before主要遵循以下几点规则:
程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
监视器规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
volatile规则:对一个volatile变量的写,happens-before于任意后续对一个volatile变量的读。
传递性:若果A happens-before B,B happens-before C,那么A happens-before C。
线程启动规则:Thread对象的start()方法,happens-before于这个线程的任意后续操作。
线程终止规则:线程中的任意操作,happens-before于该线程的终止监测。
我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
线程中断操作:对线程interrupt()方法的调用,happens-before于被中断线程的代码检测到中断事件的发生
可以通过Thread.interrupted()方法检测到线程是否有中断发生。
对象终结规则:一个对象的初始化完成,happens-before于这个对象的finalize()方法的开始。
我们首先补充两点概念:
我们最后来介绍几道经典习题
- /* 希望 doInit() 方法仅被调用一次,下面的实现是否有问题,为什么? */
-
- public class TestVolatile {
-
- volatile boolean initialized = false;
-
- void init() {
- if (initialized) {
- return;
- }
- doInit();
- initialized = true;
- }
-
- private void doInit() {
- }
- }
-
- /*解析*/
-
- 存在问题!
- 没有对init设置锁,可能会导致同时有多个线程调用,导致多次创造
- t1进入,判断未初始化,进行doInit(),t2进入,判断未初始化,也进行doInit(),然后两者才进行initialized=true的更改
- /* 代码展示 */
-
- // 问题1:为什么加 final
- // 问题2:如果实现了序列化接口, 还要做什么来防止反序列化破坏单例
- public final class Singleton implements Serializable {
- // 问题3:为什么设置为私有? 是否能防止反射创建新的实例?
- private Singleton() {}
- // 问题4:这样初始化是否能保证单例对象创建时的线程安全?
- private static final Singleton INSTANCE = new Singleton();
- // 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由
- public static Singleton getInstance() {
- return INSTANCE;
- }
- public Object readResolve() {
- return INSTANCE;
- }
- }
-
- /* 问题解析*/
- 1.(防止被子类继承从而重写方法改写单例)
- 2.(重写readResolve方法)
- 3.(防止外部调用构造方法创建多个实例;不能)
- 4.(能,线程安全性由类加载器保障)
- 5.(可以保证instance的安全性,也能方便实现一些附加逻辑)
- /* 代码展示 */
-
- // 问题1:枚举单例是如何限制实例个数的
- // 问题2:枚举单例在创建时是否有并发问题
- // 问题3:枚举单例能否被反射破坏单例
- // 问题4:枚举单例能否被反序列化破坏单例
- // 问题5:枚举单例属于懒汉式还是饿汉式
- // 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做
- enum Singleton {
- INSTANCE;
- }
-
- /* 问题解析 */
- 1.(枚举类会按照声明的个数在类加载时实例化对象)
- 2.(没有,由类加载器保障安全性)
- 3.(不能)
- 4.(不能)
- 5.(饿汉)
- 6.(写构造方法)
- /* 代码展示 */
-
- 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;
- }
- }
-
- /*问题解析*/
- (没有线程安全问题,同步代码块粒度太大,性能差)
- /* 代码展示 */
-
- 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.(防止putstatic和invokespecial重排导致的异常)
- 2.(缩小了锁的粒度,提高了性能)
- 3.(为了防止同时有线程进入,在第一个线程创建后,其他线程进入锁后再次创建)
- /*代码展示*/
-
- 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.(懒汉式,由于初始化方法是在该对象第一次调用时才初始化,同样是属于类加载不会导致该单实例对象被创建,而是首次使用该对象时才会创建)
- 2.(没有并发问题,该对象的创建是在初始化创建,初始化只有一次,不会多次创建,不会修改,也没有并发问题,由系统保护)
下面介绍一下本篇文章的重点内容: