主要内容:
多线程线程和进程间的关系Java中的线程理论Java中线程类的实现方式Java中线程的常用方法线程安全性问题线程间通信线程的死锁如何规避死锁线程安全的单例模式多线程
线程和进程间的关系
进程:计算机中每一个活跃的程序(软件),都是一个独立的进程,进程之间可以是并列的关系,也可以是相互通信的关系
线程:在同一个进程中,负责不同功能的子操作,称之为线程
计算机:国家软件进程:各省市+省部级机构线程:省下属市级单位+厅局级单位
计算机中软件并行的原理:(单CPU情况)
现在的计算机都是电子计算机,电子计算机单个CPU对进程的处理都是“伪多进程”。
计算机的主板上有一个“时钟芯片”组件,时钟芯片负责按照一定的规律发送脉冲,每一个脉冲,我们称之为一个“时间片”。
时间片就是软件执行的“令牌”,哪个软件能够抢到当前的时间片,就能够占用所需的计算机资源。反之,如果其他进程没有抢到时间片,程序将被暂时性“挂起”,直到程序(进程)抢到时间片为止
计算机中的时间片单位是“纳秒”,时间片足够小,计算机的反应足够快,所以在进程抢时间片,导致程序切换的过程中。
人类是无法察觉得,所以我们“觉得”计算机中的软件(进程)是并行的。
Java中的线程理论
Java程序中的线程机制:
猜测一个Java程序运行时,有多少个线程一起工作:
JVM运行时,需要一个线程垃圾回收机制,需要一个线程代码本身还需要一个线程
注意:一个Java程序如果要运行(刨除JVM和垃圾回收机制这些辅助线程),至少需要一个线程,这个线程就是代码本身
一个程序在main方法中执行的时候,至少有一个线程代表当前程序本身,就是main线程,也就是主线程
Java中线程的实现方式:
继承Thread类实现Runnable接口继承TimerTask类实现Callable接口
线程的生命周期问题:
怀孕阶段:创建了一个线程对象,但是这个线程尚未运行出生阶段:执行线程中的启动方法,这个线程进入准备阶段参加工作:线程得到系统时间片,有资格占用系统和硬件资源,可以执行线程的任务下班休息:线程处于休眠状态,线程占用的资源不会被交出辞职离岗:此时这个线程处于挂起状态,不参与时间片的抢夺,交出所有占用的系统资源重新上岗:此时这个线程被唤醒,重新加入到时间片的争夺当中退休回家:此时这个线程进入到了结束状态下入土为安:线程的对象呗垃圾回收器回收,这个线程彻底进入消亡状态
线程的生命周期状态:
创建线程对象:
线程尚未启动,没有加入线程池当中,不归线程池调度管理准备状态:
此时的线程已经启动,加入线程池当中,接受线程池的调度管理,但是此时线程尚未得到时间片资源运行状态:
在准备状态的基础上,线程得到时间片资源,真正执行线程中的代码休眠状态:
线程不放弃现有资源(不是时间片),但是也不执行线程中的代码,这个线程不参与时间片的抢夺,休眠状态的线程,在休眠时间到的情况下,会自动唤醒挂起状态:
挂起状态下,线程依然不抢夺时间片,但是会释放占用资源,挂起状态下的线程,需要等待其他线程的唤醒阻塞状态:
线程正在执行过程当中,等待一些其他资源,例如:IO流的读写、键盘的输入结束状态:
线程结束运行,释放所有资源,退出线程池的管理
Java中线程类的实现方式
继承Thread类:/*** 自定义线程类* 自定义线程类型的目的,就是为了重写Thread类中的run()方法* run()方法中的代码就是这个线程执行的任务*/public class MyThread extends Thread {@Overridepublic void run() {//重写run()方法,让自定义线程具有我们约定的执行内容System.out.println("Brother La is Beautiful!");}}
MyThread mt1 = new MyThread(); //创建线程对象,此时线程对象处于新建状态,没有加入线程池的管理/** 注意:* 如果直接调用一个线程对象的run()方法* 那么相当于直接调用一个普通的对象方法,* 线程依然无法加入线程池当中* 执行这个run()方法的不是线程本身,而是main线程*/mt1.run();/** 只有通过start()方法启动一个线程,* 才能够将这个线程加入线程池的管理当中* 所以,调用start()方法,才是启动一个线程的正确方式*/mt1.start();
实现Runnable接口:
public class MyRunnable implements Runnable {@Overridepublic void run() {//重写run()方法,让自定义线程具有我们约定的执行内容System.out.println("Brother La is Beautiful!");}}
MyRunnable mr1 = new MyRunnable(); //通过Runnable接口实现类创建一个线程任务对象Thread t1 = new Thread(mr1); //创建一个Thread对象,作为线程任务的执行者t1.start(); //通过任务的执行者,来执行线程任务
继承Thread类和实现Runnable接口的区别和关系:
区别:
Thread类型中,不仅仅具有run()方法(Thread也实现了Runnable接口),而且在Thread类型当中体现了线程的特性,并且提供了操作线程的各种方法;但是在Runnable接口中,只有一个run()方法,并没有体现线程的特性,也不能操作线程Thread类本身描述的是线程任务的执行者,Runnable接口的实现类描述的是线程的任务一个任务同时可以由多个执行者执行;但是一个执行者同时只能够执行一个任务;任务和执行者是一对多的关系。
我们将一个任务由多个执行者执行的状态,称之为“多线程编程”
关系:
创建一个Runnable接口实现类的对象,就相当于定义了一个线程任务,而Thread类对象,是线程任务的执行者至于一个执行者执行什么任务,取决于在创建这个执行者的时候,传递什么样的任务对象
Java中线程的常用方法
Thread中的常用方法:
实际上就是Thread类中提供的常用方法:
public class TestThread {public static void main(String[] args) {MyRunnable mr = new MyRunnable();//可以在线程的构造器中为线程命名Thread t1 = new Thread(mr, "执行线程t1"); //t1.run();//System.out.println(t1);//[1]通过start()方法讲一个线程开始执行,加入线程池的管理序列//t1.start();//[3]给线程进行命名//t1.setName("执行线程t1");//[6]给线程指定优先级,优先级的取值范围是1~10之间,越界则抛出IllegalArgumentException异常/** 线程的优先级描述的不是一个线程启动的先后顺序* 线程的优先级描述的是这个线程对资源的占用比率* 线程的优先级越高,线程对资源的占用比率就越大* 线程的优先级越高,抢到时间片的概率就越大,执行的次数就越多*/t1.setPriority(Thread.MAX_PRIORITY);/*Thread t2 = new Thread(mr);t2.setPriority(Thread.MIN_PRIORITY);t2.start();*/t1.start();}}
public class MyRunnable implements Runnable {public MyRunnable() {/** 注意:* 在线程或者Runnable的构造器中* 执行这个构造器的不是当前线程* 是调用这个构造器创建对象的程序的所在线程*/System.out.println(Thread.currentThread());}@Overridepublic void run() {while(true) {//[2]返回一个Thread对象,这个Thread对象代表当前正在执行这段代码的线程对象/** 注意:* 如果run方法被以对象方法的形式调用* 在run方法中的Thread.currentThread()返回的也不是当前线程* 返回的是调用这个run方法的线程* 因为此时,当前线程并没有加入线程池当中* 直接调用run方法相当于调用了一个普通的对象方法*///[4]获取线程的名字//System.out.println(Thread.currentThread().getName()); /** 一个线程的ID是由线程池来分配的* 这个ID不能手动赋值*///[5]获取线程的ID编号//System.out.println(Thread.currentThread().getId()); //[7]获取一个线程的优先级System.out.println(Thread.currentThread().getPriority()); /** 如果一个线程处于运行状态、准备状态、休眠状态、挂起状态、阻塞状态下* isAlive()方法,返回的都是true;* 如果一个线程处于创建状态和结束状态下* isAlive()方法,返回的都是false*///[8]判断一个线程是否处于存活状态System.out.println(Thread.currentThread().isAlive()); System.out.println("博主最帅!");//[9]线程的休眠方法try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}}}}
如何停止一个线程:
public class TestThread {public static void main(String[] args) {MyRunnable rm = new MyRunnable();Thread t1 = new Thread(rm, "我是一个执行线程");t1.start();try {Thread.sleep(3 * 1000);} catch (InterruptedException e) {e.printStackTrace();}/** 如果一个线程当前正处于休眠状态下* 此时给这个线程设置断点* 将会引发sleep方法抛出InterruptedException*/t1.interrupt(); //通过这个方法,给一个线程设置中断标记位true,代表这个线程已经被中断了}}
public class MyRunnable implements Runnable {@Overridepublic void run() {Thread currentThread = Thread.currentThread();/** 利用currentThread.isInterrupted()方法和try-catch异常处理来结束线程* 1.将线程中循环的条件置为!currentThread.isInterrupted()* 只要没有通过其他线程对当前线程进行中断,线程中的循环就会继续* 2.如果线程中存在sleep方法,那么一个线程如果处于sleep状态下,对这个线程进行中断* sleep方法将会抛出InterruptedException* 此时通过在循环外侧添加try代码段,尝试执行这个循环,* 一旦循环内部抛出异常,通过catch代码段进行捕获* 如果进入catch代码段中,相当于循环已经退出* 如果run方法后序没有其他内容,这个线程的任务就完成了,线程自动结束*/try {while(!currentThread.isInterrupted()) {//在线程内部,我们可以通过thread.isInterrupted()返回一个boolean值,表示这个线程是否被中断System.out.println(currentThread.getName() + ", " + currentThread.isInterrupted());/** sleep方法并不是百分之百安全的* 如果一个线程处于sleep状态下,此时为这个线程进行中断* sleep方法将会抛出InterruptedException* 这个异常并不可怕,最要命的是这个异常将会清除当前线程的中断状态(Interrupted Status)* 如果当前线程的中断状态被清除到false状态,这个线程中的方法,将会继续执行*/Thread.sleep(500);}}catch (InterruptedException e) {System.out.println("当前线程已经结束了");}}}
守护线程的作用:
public class TestThread {/** 父级线程和子线程的关系:* 如果一个线程之下,还会启动其他的线程* 被这个线程启动的,称之为这个线程的子线程* 当前线程称之为子线程的父级线程* 父级线程的启动顺序一定在子线程之前* * 同级别的子线程启动顺序是随机的* 首先调用start()方法的线程不一定优先取得时间片*/public static void main(String[] args) {/** 在当前程序中* 如果存在守护线程* 守护线程会在其他所有线程执行完毕之后,自动退出* 不需要手动进行守护线程的中断操作* 但是如果当前程序中还有其他线程在运行,守护线程就会一直运行下去* * JVM中,执行垃圾回收的线程,就是典型的守护线程* * 总结:* 守护线程守护的是同一个线程池当中的其他线程* 如果在同一个线程池当中具有其他线程尚未结束* 此时,守护线程也不会结束* 注意:守护线程守护的其他线程,不区分父级和子级*/DeamonThread dt = new DeamonThread();dt.setDaemon(true); //设定这个线程为守护线程UserThread ut = new UserThread();//设定这个线程为用户线程,默认情况下一个线程就是用户线程ut.setDaemon(false); ut.start();try {Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();}dt.start();while(true) {System.out.println("我是父级线程");}}}
线程安全性问题
线程安全性问题的本质:
多线程执行任务的本质是计算机的时间片占有在多个线程之间进行切换,当一个线程的步骤没有完全结束之前,其他线程有可能抢夺时间片,其他线程在获得时间片之后会占用共享资源,这就有可能导致数据的脏读写
***脏读写:***我们当前线程获得的数据是其他线程经过修改但是我们不知道的数据,脏读写可能会导致脏数据的出现
***总结:***在多线程的环境下,资源的共享有可能导致脏读写,脏读写可能导致脏数据。
为了在多线程环境下避免脏读写导致脏数据,就要给共享资源对象加上同步锁(synchronized),同步锁锁定的是共享资源对象,共享资源对象被锁定的是这个对象的使用权。
所以说,在同步锁代码块中的步骤都是对共享资源的修改步骤,这些步骤在多线程之间是同步执行的,因为这些步骤在执行的过程当中受synchronized的影响,不会释放对共享资源的占有。
在synchronized代码段当中,对资源的读写是独享(同步)的不是共享(异步)的,如果被读写的资源成为独享资源,对这个资源的操作过程就是线程安全的,就不会出现数据的脏读写,也就不会导致脏数据,如果一个过程时线程安全的,我们称这个过程是“原子化”的操作过程
不要滥用synchronized:
synchronized代码段本身是执行同步方法(代码)的作用的
如果synchronized代码段的要影响范畴过大,会造成不必要的资源浪费
例如:在火车票售票的案例中:
通过线程休眠模拟的找零钱过程,不涉及到同步执行,就不应该存在于synchronized代码段当中
@Overridepublic void run() {try {while(!Thread.currentThread().isInterrupted()) {synchronized(tp) {sale();}/** 这种找零钱的过程* 多个窗口之间互不影响* 也没有涉及到对共享资源tp的读写* 所以不需要保证同步,异步执行即可* 这种费时的操作如果在多个线程之间一步执行,可以提升多线程的总体效率*/Thread.sleep(1000); //模拟找零钱的过程}}catch(InterruptedException e) {System.out.println("车票已售完!");}}
在sleep状态下,线程对锁对象的控制:
如果一个线程处于sleep(休眠)状态下,当前线程并不会释放他所占用的锁对象。
也就是说,一个线程中如果存在休眠操作,那么这个操作一般不应该放在synchronized代码段当中。
一个线程如果处于sleep状态下,这个线程是不会参与抢夺时间片的。
在同步代码段中的锁对象是不能发生变化的:
多线程同步代码段锁定的所对象,应该在多个线程之间共享,具有共同的约束力。
如果一个锁对象在多个线程之间会发生变化,就不会具有多线程之间的共同约束力。
不具有共同约束力的锁对象,依然会引起线程的不安全,所以,类似String和包装类对象的,存在自动建包的对象,一般不能作为锁对象使用。
线程间通信
概念:一个线程启动之后,可以对其他线程进行通知,通知其他线程启动或等待
典型应用:生产者消费者模型
手段:wait()/notify()/notifyAll()
睡美人的故事:
从前,有一个公主(一个线程),因为这个公主太漂亮了,被一个巫师(男的)看上了
公主不喜欢巫师,于是,在一个夜黑风高的晚上,巫师潜入城堡,锁上门……
给公主下了一个咒语,公主睡着了(线程进入挂起状态)
N多年之后……
巫师摇身一变,成为XX国王子,再次回到这个城堡中
进入了那一道他熟悉的门中,锁上门……
这个王子(巫师本人),终于得偿所愿,亲了公主一口
结果因为嘴太臭,公主被熏醒了(线程被唤醒)
从此公主和王子(巫师)过上了羞羞的生活……(全剧终)
从故事中得到的技术启发:
如果一个线程在运行过程中,有一个对象在这个线程中调用wait()方法,这个线程将进入等待(挂起)状态;如果其他线程中,有对象调用notify()/notifyAll()方法,挂起的线程有可能被唤醒只有在synchronized代码段中,才能够调用wait()方法或者notify()/notifyAll()方法,否则将会抛出异常:IllegalMonitorStateException等待之前加锁的对象、执行wait()方法的对象、唤醒之前加锁的对象、执行notify()/notifyAll()方法的对象,必须是同一个对象sleep方法和wait方法,都会让线程暂停(这个线程不去争抢时间片),但是在sleep引起的休眠状态下,当前休眠的线程不会释放线程锁对象,其他线程依然无法取得这个锁对象;在wait引起的挂起状态下,挂起线程会释放线程的锁对象,其他线程可以对这个锁对象进行争取通过wait方法进入挂起状态的线程,一旦被唤醒,将从wait()方法之后继续执行这个线程的run方法notify()方法是随机唤醒线程池当中的一个挂起线程,至于唤醒谁,不一定;notifyAll()方法会对线程池当中所有的线程发送唤醒信号,挂起的线程会被唤醒,没有挂起的线程将忽略这个信号
案例1:
一个生产者,一个消费者,仓库容量为1
案例2:
三个生产者,三个消费者,仓库容量为1
遇见问题:
问题1:过度生产和过度消费
问题原因:
notify()方法的作用是随机唤醒线程池中的一个挂起线程,如果上一个执行线程的是生产者1号,生产者1号唤醒的是生产者2号,那么从wait()方法的特点来说,我们会从生产者2号的wait()方法继续向下执行,不会回头重新执行if判断,最终导致过度生产,过度消费的原理也是相同的
解决方案:
将生产方法和消费方法中的if判断语句,改为while循环语句
在wait()方法结束,线程被唤醒的时候,重新执行while循环的条件判断
将一次性的if使用可重复的while取代
问题2:6个线程都卡死了(死锁问题)
问题原因:
死锁的原理:一个线程的代另一个线程持有的锁对象,但是另外一个线程始终不释放这个所对象
本例中死锁的原因:
消费者唤醒消费者,生产者唤醒生产者,最终导致所有的生产者和消费者都进入等待状态,此时的线程锁被空置,此时没有其他线程能够唤醒等待的线程
解决方案:
使用notifyAll()方法替换生产过程和消费过程当中的notify()方法
每一次都对所有的线程发出唤醒信号,等待的线程都被唤醒,没有等待的线程忽略这个信号
线程的死锁
死锁的概念:
在多个线程之间,具有多个共享资源,其中一个线程如果想要运行,需要占用1个以上的公共资源。
如果一个线程在占据1(n)个公共资源的前提下,去争夺剩余的公共资源,如果争取不到,原有的公共资源不进行释放。
那么就有可能导致其他的线程也得不到所需的所有公共资源,导致所有的线程都进入挂起状态等待资源,这种状态就是多线程的死锁状态。
死锁状态的典型案例是:哲学家就餐问题
哲学家就餐问题的描述:
一张桌子周围有5位哲学家,每位哲学家每天的状态只有两种:吃饭状态和思考状态,每位哲学家在吃饭的时候,需要获取左右手两边的叉子才能够就餐如果所有的哲学家,我们统一约定:
编号为n的哲学家能够获取的叉子编号为:n和(n+1)%5同一位哲学家只有同时获取左右两支叉子才能够就餐,否则等待叉子资源每一位哲学家就餐的时间是1000毫秒,在这1000毫秒之中,占用两把叉子各位哲学家要获取叉子的话,首先获取n号叉子,其次获取(n+1)%5号叉子如果一位哲学家获取了一把叉子,这位哲学家将会在保持这把叉子占有的情况下,争取另一把叉子
哲学家就餐问题导致死锁的原因:
如果5位哲学家同时需要就餐,那么5位哲学家将会同时拿起和自己具有相同编号的叉子,将会导致n-1号哲学家永远无法得到另外一把叉子,将会永远处于永远等待资源的状态,此时,死锁产生了。
在哲学家就餐问题中定义的类型:
1.定义叉子类型:
在这个类型当中,我们定义一个长为5的boolean数组forks,数组的每一位都代表下标位n的叉子的占用情况,如果下标位n的叉子取值为true,代表这个叉子正在被占用;如果取值为false,代表当前叉子空闲,在这个类型中,还要定义获取叉子和释放叉子的方法
获取叉子的方法:
传递一个哲学家对象进来,从这个哲学家的对象中获取哲学家编号根据哲学家的编号,计算这个哲学家需要获取哪两把叉子定义两个额外的boolean值,表示当前哲学家是否成功获取两把叉子获取左叉子,如果获取成功,将forks数组中的n改变为true;如果获取失败,当前哲学家进入挂起状态在获取左叉子的基础上,获取右叉子,如果获取成功,将forks数组中的(n+1)%5改变为true;如果获取失败,当前哲学家进入挂起状态如果哪一吧叉子没有成功获取,当前哲学家再被唤醒的时候,重新尝试获取这个叉子如果两把叉子都能够拿到手,这个哲学家就能够正常就餐
释放叉子的方法:
传递一个哲学家对象进来,获取哲学家的编号计算释放叉子的位置放下左叉子,forks[n] = false;放下右叉子,forls[(n+1)%5] = false唤醒其他哲学家线程,进入唤醒状态
注意:在获取叉子和放下叉子的过程中,都属于对共享资源的读写,需要加锁保证线程安全
2.定义哲学家类型:
为了方便起见,哲学家类型可以直接定义为Thread类型的子类,在哲学家类型中定义思考和吃饭的方法
思考的方法:
输出当前哲学家名字,进入思考状态当前哲学家线程休眠500毫秒输出当前哲学家名字,结束思考状态
吃饭的方法:
输出当前哲学家名字,进入吃饭状态当前哲学家线程休眠500毫秒输出当前哲学家名字,结束吃饭状态
run方法中,定义的内容:
定义一个死循环,表示当前哲学家在就餐和思考状态之间不断切换循环中,首先进入思考状态循环中,获取叉子循环中,进入就餐状态循环中,放下叉子
注意:在多线程编程的环境下,应该尽量避免死锁问题的出现
如何规避死锁
1.线程自律性方案:
当一个线程在申请多个资源的时候,首先判定是否能够一次性获取所有的资源,如果能够一次性获取所有的资源,再去占用这些资源,反之,如果不能一次性占用所有资源(包含仅占用一部分资源的情况),当前线程将不占用任何资源,剩余的资源将分配给其他线程进行使用,保证其他线程有机会正常运行
在当前案例中体现自律性的代码方案:
在哲学家申请叉子的情况下,不是直接分配能够到手的叉子而是首先通过对叉子占用状态的判断决定是否一次性分配两把叉子如果两把叉子中有一把(以上)的叉子不能分配给当前哲学家两把叉子就都不分配给当前哲学家,哲学家直接进入挂起状态此时的哲学家线程将不占用任何相关资源
2.线程他律性方案:
创建一个资源管理器对象,通过资源管理器统一管理所有的公共资源
公共资源管理器具有分配资源的方法,线程在需要资源的时候,不是直接访问资源,而是想资源管理器发出请求。
资源管理器根据线程申请资源的状态,对申请线程进行响应(给申请线程一个返回值),如果资源管理器同意线程对资源占用的申请,线程将能够得到需要的资源进行运行。
反之,如果资源管理器不同意线程对资源占用的申请,那么当前线程将进入挂起状态,如果线程运行结束,释放资源,释放资源的操作也是线程申请资源管理器去执行。
代码实现方案:
在案例中定义一个“管家”类型,将分配叉子和回收叉子的方法从Fork类当中分离出来此时的Fork类型仅是当做资源使用如果一个哲学家想要进入就餐状态,首先调用管家对象的叉子分配方法如果管家同意哲学家就餐,分配方法就返回true,哲学家获得叉子(在管家的分配方法中执行),哲学家正常就餐如果管家不同意哲学家就餐,说明当前资源不足,当前哲学家不能获得任何资源(有一部分也不给)如果此时管家不同意,那么当前哲学家线程进入挂起状态挂起的哲学家线程,等待其他哲学家线程就餐完毕的notifyAll()唤醒信号一旦被唤醒,再次请求管家,判断管家是否能够为自己分配叉子资源此时,哲学家就餐完毕后,放下叉子的动作也要通过调用管家对象的方法来执行
注意:管家对象最大的功能就是分配和回收资源,此时多个哲学家线程能够直接访问的公共对象不再是Fork类型对象,因为Fork对象归管家管理,此时多个哲学家线程共享的公共对象应该变为管家对象
线程安全的单例模式
回忆一下懒汉模式下的单例:
/*** 通过懒汉模式实现的单例*/public class Singleton {//[2]声明单例变量,作为类属性使用private static Singleton singleton = null;//[1]私有化构造器,防止外界通过构造器创建对象private Singleton() {}//[3]创建一个类方法,只能够通过类名调用,这个方法返回唯一的单例对象public static Singleton getObject() {//现用现加载if(singleton == null) {singleton = new Singleton();}return singleton;}}
在多线程情况下,懒汉单例模式遇见的问题:
在多个线程中如果同时获取单例对象,并打印其HashCode编码,有可能出现非单例情况
解决方案:DCL(Double Check Lock:双检查锁)
第一道锁:
给获取单例对象的方法加上锁,加的锁是“静态锁”,静态锁的synchronized关键字传递的对象是类名.class,类名.class实际上返回的是Class类型的对象,如果给一个方法或者一个代码段加上静态锁,将会导致这段代码的任何一种访问方式,都是同步执行的,因为静态锁锁定的是当前类的使用权,所以对这个类的任何一种访问方式都是同步的第二道锁:
volatile关键字:
JVM虚拟机的两种状态: server:服务端状态client:客户端状态
volatile关键字的作用:这是一个轻量级的锁
凡是带有volatile关键字的属性,在多线程对其读写的时候,强制及时更新
也就是说,凡是带有volatile关键字的属性都不会被拷贝到线程的私有空间当中。
所有线程对这个属性的读写操作都是基于这个属性值本身的读写操作,而不是针对线程私有空间中属性的读写操作,换句话说,volatile关键字会强制将client状态下的属性转换位server状态下的属性
在单例模式中,我们可以将volatile关键字加给单例对象那个类属性,保证在多个线程之间,访问到的单例对象属性是同一个,让多个线程能够及时发现这个单例对象是否已经被创建。