美国上市公司,专注Java培训22年

J.U.C系列-线程安全的理论讲解


在J U C里面,要谈到并发,就必然就存在可见性问题,其实对于程序来讲,要说到锁,首先要确保可见性,也就是要在这个基础上才能做到,而CAS也是基于这种原理来完成,关于Atomic的介绍中有提到通过unsafe调用底层的compareAndSwapXXX的三个方法,都是基于可见性变量才会有效。

【J.U.C系列-线程安全的理论讲解】

谈到可见性,首先要明白一下内存和CPU以及多个CPU之间数据修改的基本原理,我们不要谈及CPU上太深的东西,我只需要明白,要将数据进行运算,就需要将数据拷贝到CPU上面去计算,那么就会有内存和CPU之间的通信、CPU的计算、写回到内存一些动作,此时基于线程的私有栈中会保存这些数据;而可见性会体现在:当另一线程对共享数据进行修改的时候,另一个线程未必能看到或者未必能马上看到这个数据。那什么叫看到这个数据呢?说起来蛮抽象的,并且这些情况通常不好模拟,在不同的CPU下也会模拟出来不同的效果或者根本模拟不出来(所以本文只会给出很多理论,因为给你的代码你可能会认为他们是无法将场景实现的),我们下面用简短的一段例子描述下大概:

当一个线程创建多个子线程去做很多任务的时候,在每个子线程内部的都有一个状态区域设置(例如:初始化、运行中、执行完成、执行失败等),主线程会不断去读取子线程的状态,从而做进一步的操作;上面所提到的可见性就是体现在当主线程去读取子线程的数据的时候,有可能会导致数据的还是“老”的值或“失效”的值的情况,但是并不是任何时候都出现,只是一些偶然的情况会发生,由于某些CPU的优化或当JVM被调节为-server模式下运行时,允许很多信息被优化后才会发生;所以你经常在本地调试一些并发程序发生没有什么问题,当你发布到server下后,经常会出现一些稀奇古怪的问题,这是为什么呢,程序的优化和CPU的优化,它认为这里应该是安全的,可以被优化或转换,如果你不想让他变化,你就需要告诉他们,你的数据是存在多线程安全隐患的。

文章中java培训班会介绍很多关于线程安全的知识理论分享,也许你第一遍看下来头晕脑胀,但是通过理解后再看看,也许你就会有很多自己的理解,从而在多线程编程时对于线程的安全有新的认识。

什么是线程安全?

从上面的信息可以发现,问题通常出现在多个线程之间的共享数据的访问,也就是没有“共享”就不会出现征用;当多个线程并发得读或写一些共享的数据的时候,我们就可能会产生各种各样的问题,例如上面提到的可见性问题,但是可见性并不代表原子性,因为原子性要求读、修改、写入三个动作要一致,所以原子性要求更高,而原子性代表不了锁,锁要求这个片段的执行或相关片段的执行都是相互隔离的,也就是他不仅仅是单个步骤或某个变量操作需要原子的,而是整个这些步骤操作都是相互隔离的。

栈隔离:

要让线程安全,最简单的方法就是栈隔离,有些翻译为栈封闭,也就是每个线程之间的信息都是局部变量,相互之间是不存在读写的,有本地的局部属性,也有可能是ThreadLocal的延伸,他们都是线程隔离的,通常WEB应用的系统业务代码都是栈隔离的,并不是代表WEB应用是栈隔离的,因为WEB容器帮我们把复杂的线程分派等工作处理掉了,业务代码大部分情况下无需关心多线程处理而已。

可见性:

为了说明可见性,我们来写一个例子程序,代码如下,复制到你的机器上就可以运行:

【J.U.C系列-线程安全的理论讲解】

这个代码很简单吧,就是一个线程对另一个线程的数据进行了修改,然后看下结果;可能你觉得很无聊,这个结果很明显,然后拿到IDE工具下一运行结果是延迟两秒后就输出来是两个true;但是不然,你要运行这段代码,你需要将运行设置为-server状态,要么在命令行下运行,要么要设置IDE工具运行这个java程序时需要携带的命令,eclipse就是可以在Run Configurations->Arguments->VM arguments里面增加-server即可;

运行结果可能有多重,看机器、看OS、和VM版本;

如果你用的hotspot的VM,可能出现的结果有:

1、正常输出两个true,说明正好被赶上了或OS和机器未做一些处理;

2、主线程输出了一个false,子线程正常退出,看到了true;

3、主线程输出了true,子线程未看到,始终在死循环;

真的假的,你试一试就知道了,呵呵;我的机器上出现的是第三种情况,上述代码中如果将while循环内部写为yield就不会出现死循环的情况,他空闲出对CPU的使用,在获取变量时会重新进行一次拷贝。

其实我们在刚开始引文中已经大概说到了可见性的问题,我们具体来说说什么情况会出现,例如,在一个类中有多个属性,其中一个属性来标示状态(status),其他的属性来标示这个属性的值(name、number等),某一个线程正在等待这个类的值被填充,填充的标志位status,可能线程的代码为:

name= “aaa”;

number= 12;

status= true;

也就是将name和number写入完后开始写入status,这表面上看上去没有什么问题,是的,但是随着编译器发现这三个赋值完全是没有任何顺序关系的,所以在运行一段时间后,随着JIT和CPU的优化,会导致他们执行顺序的乱序,也就是他们三条代码的执行顺序未必是一致的,当status的值被先被赋予true,而name和number可能还未被赋值,所以另一个线程可能会得到的name是null或以前赋值过的信息;

而还有什么可能呢,在某些特殊的情况下,status可能被赋值了true,而另一个线程一直看不到,那么等待这个对象被赋值的线程会出现死等的情况。

再深入一下,对于jvm来讲,很多时候他并不认为这个线程赋值不是安全的,因为它并不知道你有多个线程要操作这个对象,所以他通常在对long、double类型的赋值或读取的时候,会按照32个bit(4个字节)一个基本操作为基本单位,这样可能会造成的是,当读取了前面4个字节后,这个内存单元被修改,此时后面4个字节发生了变化,那么读取出来的数据可想而知。

那么如何保证可见性呢?volatile,这就是volatile真正的意义,要保证原子性,首先要保证可见性,因为你看到的都不是真的,就没法保证数据是原子的;volatile有三大特征:

1、 要求编译器对指令是顺序的,优化器对相关变量赋值的顺序是不改变的;CPU不做相关的指令顺序;

2、 每次访问volatile会向纵向发起一个简单的lock,用于做add(0)的操作,一个轻量级的锁,并从内存中获取最新的数据;

3、 对于long、double类型的数据,读取他们的时候,会是原子的,也就是两个步骤会产生一个简单的锁。

volatile由于在读写时发生一个短暂的锁,所以他的性能会比普通的变量稍微低一点,所以你在后面提到的很多情况下,无需将所有的内容都设置为volatile,因为这样会降低系统的性能。

volatile变量仅仅能保证可见性,也就是你在读取的一瞬间这个数据是不会被修改的,但是要达到原子或锁的目的是不行的,接下来,我们再看一个线程安全,但是可能很多人不想看的final,但是他在线程安全中的确有一些重要的作用:

final使用:

在很多应用中,经常发现定义的变量出现了final,但是自己不知道怎么用,除了他不可改变以外,其实他另一种重要用途就是线程是绝对安全的,当一个引用或一个变量被定为final,他在多线程中自然只有读的操作,而没有写的操作;但是这并不意味着这个对象本身内部的所有属性的访问是线程安全的,如果某些属性是被多个线程所访问的,如果可以被认为他们是不会改变的,那么属性也应当是final的;

在很多系统的代码中经常会出现init()或initialize()这些方法,他们如果没有被类似构造方法或某些特殊的基于锁的方法调用的话,就会出现一些问题;由于他们的调用可能会是被并发调用的,如果你没有加锁的情况下,内部的某些属性,你又想让他被初始化一次,这就是不可能的了;当然你在构造方法中可以去调用,那么就涉及到外部的一个线程安全,此时对于很多场景来讲,是推荐使用final,因为它在初始化的时候强制要求被赋值或必须在构造方法中被赋值,不是final类型的,即使你没有对它做任何操作,它在构造父亲类Object的时候会给所有的属性做一次初始化操作,使得这些变量的值是“老”值;当某个线程获取到对象的引用后,调用相关的初始化方法来初始化,而第二个线程进来的时候,发现还未被赋值,继续初始化,等等会产生各种问题。

而还有一类比较重要的问题,就是当一个对象被定义为final,也就是不可以改变的对象,这个对象内有很多属性也不可以改变,此时虽然定义成了final,但是如果提供了对该对象的get方法,外部线程获取到后同样可以修改内部的属性,所以要将内部属性不可改变,同样需要将其定义为final。

某些变量是内部使用的值,子类可能也会被使用,那么可能会被定义为protected类型的,这些类行的方法和属性通常是不会被访问到的,但是通过继承或内部实例就,可以在内部使用一个匿名块或方法,然后使用this访问到这些属性或方法,从而进一步得到数据,所以protected的一些属性在Java并发编程中也是需要被慎重使用的。

拷贝实现不可变和线程安全:

上面提及到了某些共享的数据是不可变的,可能是一个对象、数组或某个集合类等,虽然我们在管理这些数据的时候使用了final,但是他们本身内部的属性并非final,例如数组获取到后,可以对数组内部的某个下标做修改,而集合类对象也是如此;

在这种情况还有一种方式就是拷贝,将数据拷贝一份给使用者,使用者的修改并不会影响原有数据的信息,也许使用者的确会根据这些模板来做一些个性化的调整(Prototype),此时的方法就是利用克隆,而数组也可以使用Arrays.copyOf方法来操作,集合类就使用Collections里面的相关方法;但是要注意的是,这些拷贝方法就是拷贝当前这一层,不论是克隆还是下面的拷贝,如果还有深入引用,需要自己进一步去拷贝才可以达到效果,否则更深一层的内容的修改同样会影响这些数据;例如,一个数组中每个引用都引用了一个Person对象,那么拷贝的结果并没有创建很多新的Person对象,而是只生成一个新的数组,将原有数组上所有指向Person的地址内容拷贝过来而已。

事实不可变:

什么叫做事实不可变,就是说这个变量虽然我没有定义为final,而且多线程会访问,但是他在运行时是不会改变的,也就是语法上允许改变,但是业务代码不会有对他的写操作;那么访问这些对象或变量是无需加锁的,他们被任意组装到数组、集合类或对象中,只要数组和集合类或对象本身是线程安全的,访问他们都是线程安全的。

也就是你知道这个对象的内容是不会变化的,你就无需对他进行锁操作,以提高程序的整体性能,避免不必要的锁开销。

原子性:

这里提到的原子性,就是指对某个内存单元进行读写操作是一致的,类似一次count++的操作,会经历:获取count的值、在CPU上计算结果、将count的结果写回到内存单元;

而volatile只能保证一个点上的一致性的,不能保证一个过程,所以要保证过程的一致性,就需要有锁的概念引入,synchronized、Lock系列我们会在后续的文章中介绍,而对于单个内存单元来讲,我们实用Atomic系列的功能就足以解决,它采用CAS的方式完成,基于unsafe提供的compareAndSwapXXX三个核心方法,这是CPU上的条件指令,也就是每次修改完后会做一次对比,若一致就认为成功,否则失败返回falase,那么对于可见性的volatile加上他们的组合,就可以完成CAS的功能。

包含老Atomic类对基本变量、引用、数组等内容的一致性修改操作;Atomic系列基于volatile来实现,锁机制比volatile更加强,对于内存单元的访问,它的速度比volatile要更低一些,但是内存单元的修改来讲,它在并发编程中是最简单的,除了Lock和synchronized外的一个选择,大部分情况下他在对单个内存单元上的修改的性能要比Lock和Synchronized要好。

java并发编程中,本文是一个引导性的作用,认识到了多线程访问的重要性,接下来就是针对问题如何去解决,当然本文也给出了一些基本的变量处理方式,但是JUC中还有很多的内容,需要逐步去挖掘,例如我们即将要介绍的锁机制和并发集合类的相关操作。

感谢大家阅读由java培训机构分享的“J.U.C系列-线程安全的理论讲解”希望对各位学员有所帮助,更多精彩内容请关注Java培训官网

免责声明:本文由小编转载自网络,旨在分享提供阅读,版权归原作者所有,如有侵权请联系我们进行删除


【免责声明】本文部分系转载,转载目的在于传递更多信息,并不代表本网赞同其观点和对其真实性负责,如涉及作品内容、版权和其它问题,请在30日内与我们联系,我们会予以重改或删除相关文章,以保证您的权益!

Java开发高端课程免费试学

大咖讲师+项目实战全面提升你的职场竞争力

  • 海量实战教程
  • 1V1答疑解惑
  • 行业动态分析
  • 大神学习路径图

相关推荐

更多

Java开班时间

收起