在J U C里面,要谈到并发,就必然就存在可见性问题,其实对于程序来讲,要说到锁,首先要确保可见性,也就是要在这个基础上才能做到,而CAS也是基于这种原理来完成,我们在文章: Java JUC之Atomic系列12大类实例讲解和原理分解 中关于Atomic的介绍中有提到通过 unsafe 调用底层的 compareAndSwapXXX 的三个方法,都是基于可见性变量才会有效。
谈到可见性,首先要明白一下内存和CPU以及多个CPU之间数据修改的基本原理,我们不要谈及CPU上太深的东西,我只需要明白,要将数据进行运算,就需要将数据拷贝到CPU上面去计算,那么就会有 内存和CPU之间的通信、CPU的计算、写回到内存一些动作 ,此时基于线程的私有栈中会保存这些数据;而可见性会体现在:当另一线程对 共享数据 进行修改的时候,另一个线程未必能看到或者未必能马上看到这个数据。那什么叫看到这个数据呢?说起来蛮抽象的,并且这些情况通常不好模拟,在不同的CPU下也会模拟出来不同的效果或者根本模拟不出来(所以本文只会给出很多理论,因为给你的代码你可能会认为他们是无法将场景实现的),我们下面用简短的一段例子描述下大概:
当一个线程创建多个子线程去做很多任务的时候,在 每个子线程内部 的都有一个状态区域设置( 例如: 初始化、运行中、执行完成、执行失败等),主线程会 不断去读取子线程的状态 ,从而做进一步的操作;上面所提到的 可见性就是体现在当主线程去读取子线程的数据的时候,有可能会导致数据的还是“老”的值或“失效”的值的情况 ,但是并不是任何时候都出现,只是一些偶然的情况会发生,由于某些CPU的优化或当JVM被调节为-server模式下运行时,允许很多信息被优化后才会发生;所以你经常在本地调试一些并发程序发生没有什么问题,当你发布到server下后,经常会出现一些稀奇古怪的问题,这是为什么呢,程序的优化和CPU的优化,它认为这里应该是安全的,可以被优化或转换,如果你不想让他变化,你就需要告诉他们,你的数据是存在多线程安全隐患的。
文章中会介绍很多关于线程安全的知识理论分享,也许你第一遍看下来头晕脑胀,但是通过理解后再看看,也许你就会有很多自己的理解,从而在多线程编程时对于线程的安全有新的认识。
从上面的信息可以发现,问题通常出现在多个线程之间的共享数据的访问,也就是没有“ 共享 ”就不会出现征用;当多个线程并发得读或写一些共享的数据的时候,我们就可能会产生各种各样的问题,例如上面提到的可见性问题,但是可见性并不代表原子性,因为原子性要求读、修改、写入三个动作要一致,所以原子性要求更高,而原子性代表不了锁,锁要求这个片段的执行或相关片段的执行都是相互隔离的,也就是他不仅仅是单个步骤或某个变量操作需要原子的,而是整个这些步骤操作都是相互隔离的。
要让线程安全,最简单的方法就是栈隔离,有些翻译为栈封闭,也就是每个线程之间的信息都是局部变量,相互之间是不存在读写的,有本地的局部属性,也有可能是 ThreadLocal 的延伸,他们都是线程隔离的,通常WEB应用的系统业务代码都是栈隔离的,并不是代表WEB应用是栈隔离的,因为 WEB容器帮我们把复杂的线程分派等工作处理掉了 ,业务代码大部分情况下无需关心多线程处理而已。
为了说明可见性,我们来写一个例子程序,代码如下,复制到你的机器上就可以运行:
public class NoVisiability { private static class ReadThread extends Thread { private boolean ready; private int number; public void run() { while(!ready) { number++; } System.out.println(ready); } public void readyOn() { this.ready = true; } } public static void main(String []args) throws InterruptedException { ReadThread readThread = new ReadThread(); readThread.start(); Thread.sleep(2000); readThread.readyOn(); System.out.println(readThread.ready); } }
这个代码很简单吧,就是一个线程对另一个线程的数据进行了修改,然后看下结果;可能你觉得很无聊,这个结果很明显,然后拿到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和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 的;
在很多系统的代码中经常会出现 init() 或 initialize() 这些方法,他们如果没有被类似构造方法或某些特殊的基于锁的方法调用的话,就会出现一些问题;由于他们的调用 可能会是被并发调用 的,如果你没有加锁的情况下,内部的某些属性,你又想让他被初始化一次,这就是不可能的了;当然你在构造方法中可以去调用,那么就涉及到外部的一个线程安全,此时对于很多场景来讲,是推荐使用final,因为它在初始化的时候强制要求被赋值或必须在构造方法中被赋值,不是final类型的,即使你没有对它做任何操作,它在构造父亲类 Object 的时候会给所有的属性做一次初始化操作,使得这些变量的值是“ 老 ”值;当某个线程获取到对象的引用后,调用相关的初始化方法来初始化,而第二个线程进来的时候,发现还未被赋值,继续初始化,等等会产生各种问题。
而还有一类比较重要的问题,就是当一个对象被定义为 final ,也就是不可以改变的对象,这个对象内有很多属性也不可以改变,此时虽然定义成了final,但是如果提供了对该对象的get方法,外部线程获取到后同样可以修改内部的属性,所以要将内部属性不可改变,同样需要将其定义为final。
某些变量是内部使用的值,子类可能也会被使用,那么可能会被定义为 protected 类型的,这些类行的方法和属性通常是不会被访问到的,但是通过继承或内部实例就,可以在内部使用一个匿名块或方法, 然后使用this访问到这些属性或方法 ,从而进一步得到数据,所以protected的一些属性在Java并发编程中也是需要被慎重使用的。
ThreadLocal已经在专门的文章中讲到,请参看文章:
ThreadLocal实现方式&使用介绍—无锁化线程封闭
上面提及到了某些共享的数据是不可变的,可能是一个对象、数组或某个集合类等,虽然我们在管理这些数据的时候使用了 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系列的文章,在:
Java JUC之Atomic系列12大类实例讲解和原理分解
包含老Atomic类对基本变量、引用、数组等内容的一致性修改操作;Atomic系列基于volatile来实现,锁机制比volatile更加强,对于内存单元的访问,它的速度比volatile要更低一些,但是内存单元的修改来讲,它在并发编程中是最简单的,除了Lock和synchronized外的一个选择,大部分情况下他在对单个内存单元上的修改的性能要比Lock和Synchronized要好。
在java并发编程中,本文是一个引导性的作用,认识到了多线程访问的重要性,接下来就是针对问题如何去解决,当然本文也给出了一些基本的变量处理方式,但是JUC中还有很多的内容,需要逐步去挖掘,例如我们即将要介绍的锁机制和并发集合类的相关操作。
本文先介绍到这里,相信对于以前没接触过并发编程的人来讲,有点晕,没事,多理解下就不晕了。