Java对象的共享
可见性
1 | public class NoVisibility { |
这段代码,在JDK8以前的版本中,可能会出现死循环或者输出0的情况,这里需要考虑线程执行到一半被中断的情况。
ReaderThread可能会看不到ready的值,导致它会一直处于循环状态。因为Java每个线程对数据的修改只能在工作内存中,然后同步会主内存。另外的一个线程需要从主内存读取才可以看到数据的变化。
而输出0的原因是指令重排序,也就是说虚拟机优化后,先执行了ready = true的设定,这一步被ReaderThread看到,而给number赋值的操作在执行时,ReaderThread线程已经执行完毕了,所以打印了0。
但是这段代码在JDK8及其以上版本不会出现问题,因为在JDK8及其以上版本的Java内存模型中,针对静态域和final域的处理有所改进,多个线程间对这些域的访问不再存在可见性问题。
失效数据
上述代码出现循环的情况展示了非同步导致的一个问题,失效数据。也就是说ReaderThread线程读到的ready值是隐式初始化的一个false,但是这个false其实已经被改为了true,但是它却错误的读到了false。
非原子的64位操作
非volatile类型的64位的long和double,JVM允许将64位的读操作和写操作分解成两个32位的操作,如果对该变量的读操作和写操作不在同线程中执行,那么可能会读到某个数的高32位和另一个值得低32位,导致线程不安全。
加锁与可见性
加锁得含义不仅仅局限于互斥行为,还包括可见性。为了确保所有线程都能看到共享变量得最新值,所有执行读操作或者写操作得线程都必须在同一个锁上同步。
volatile变量
在Java内存模型中,volatile关键字的可见性是通过使用内存屏障来保证的。内存屏障是CPU指令的一种,可以强制CPU在指令序列中插入一条特殊指令,它会让CPU在执行到该指令时停下来,然后刷新缓存中的数据,让数据立即写入主内存,同时让其他CPU缓存中的数据无效,让其他CPU从主内存重新读取数据。
当一个变量被volatile关键字修饰时,Java编译器会在生成的字节码中插入内存屏障指令,这样在访问volatile变量时,读线程会强制从主内存中读取该变量的最新值,而不是使用本地缓存中的旧值。类似地,写线程写入volatile变量时,会强制将该变量的值刷新到主内存中,而不是仅仅保存在本地缓存中。
由于内存屏障的存在,保证了volatile变量的读写操作具有原子性和可见性。读线程读取到的是最新值,写线程写入的也是最新值,其他线程在读写该变量时也能读写到最新的值。因此,使用volatile关键字修饰的变量可以在多线程并发访问时保证数据的正确性。
发布与逃逸
发布指的使一个对象可以在当前作用域之外的地方使用。例如将一个指向局部变量的指针保存在其他代码可以访问的地方。发布最简单的做法是将一个对象的引用放到一个共有的静态变量当中。
1 | public static Set<Secret> knowSecrets; |
而发布knowSecrets对象是,可能会导致在该集合中的对象被间接的发布,因为能拿到knowSecrets,就可以操作其里面的内容。
一个this引用逃逸的例子
1 | public class ThisEscape { |
这里涉及到匿名内部类,一个匿名内部类在构造时,编译器会默认把父类的引用隐式的传进来。也就意味着,上面的onEvent方法,他调用的doSomething可能是父类的某个方法,只不过采用省略写法,没有用this.doSomething(e)。以下是换一种不抽象的写法:
1 | public class ThisEscape { |
以上这种情况,如果在外部类,也就是ThisEscape初始化时,对num进行赋值,初始化还未完成时,却可以通过内部类对num进行修改,就会导致在num未初始化完成时,对其进行了修改。出现这种现象的原因是,ThisEscape的引用被EventListener提前暴露出去。
使用工厂方法来防止this引用逃逸
1 | public class SafeListener { |
这种方法,使得注册监听在构造方法之后完成,所以不会导致引用逃逸。
参考
《Java并发编程实战》