共享对象的正确使用
概述
在JMM,Java内存模型中讲过,其实并发编程的问题主要在多线程对共享变量的修改读取上,那么互斥(加锁),其实是通过同步来避免多个线程在同一时刻访问共享变量,但是其实并不是所有的场景都需要加锁。比如并不是所有的场景都会更改共享变量的值,它们仅仅需要把主内存的数据读取出来,然后在工作内存中拷贝一份来读取(线程本地存储);也不是所有的场景都要求立马读取最新的数据(cop-on-write);甚至有些场景直接不共享变量的值—线程封闭;又或者这个变量的值永远都不会改变—不变模式。
本文首先会介绍如何正确的封装与发布共享变量,然后会按照不同的需求推荐并发访问策略。
锁会在后面重点描述,所以本文略过
封装共享变量
如果从面向对象的思想来考虑并发编程的安全性,其实就是将共享变量作为对象属性封装在内部,然后对所有公共的方法指定并发访问策略。但是这就涉及到两个问题——在并发编程下,保证对象的可见性与正确的发布对象,防止对象逸出。
对象的可见性
这里指的可见性与JMM中提到的可见性其实是一个意思。在多个线程同时访问一个共享变量的情况下,可见性的问题尤为重要:
试想一下,i++的例子中,线程1把i由0改成了1,线程2同时也把i由0改成了1,如果变量i没有任何同步措施保证,那么线程1跟2同时把工作内存中的i回写到主内存,那么主内存中的i最终值就是1,因为线程1跟线程2中各自工作内存中变量i的值互相不可见。
保证对象可见性有很多种方案,根据JMM的Happends-Before原则,可以通过加锁与volatile两种方式。
加锁
加锁并不仅仅局限于互斥行为,它也包括内存可见性。
当线程A执行到某个同步代码块,线程B随后进入到同一个锁保护的同步代码块,那么根据Happens-Before原则,线程A释放锁后,线程B获得了锁,那么线程A对同步代码块中所有的操作结果对线程B都是可见的。
volatile
volatile是Java提供的一种稍弱的同步机制。它主要可以做两件事:确保将变量的更新操作通知到其他线程;不会将该变量上的操作与其他内存操作一起重排序。简单的说,就是可见性与防止指令重排。volatile变量不会被缓存到寄存器或者其他对处理器不可见的地方,因此读取volatile类型的变量时总会返回最新的值。
volatile原理
“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”——《深入理解Java虚拟机》
lock前缀指令相当于一个内存屏障,主要做了这几件事:
-
lock操作相当于一个写屏障,指令重排的时候不能把后面的指令重排到内存屏障之前的位置
-
lock前缀使得本CPU的cache写入了缓存,相当于会立刻把工作内存中这个变量的值回写到主内存
-
该写入动作也会引起别的CPU或者别的内核的缓存无效,强制别的线程的工作内存中,这个变量的缓存无效,要求别的线程立刻从主内存读取这个变量的值
通过这个操作也就可以保证volatile变量的修改对其他CPU立即可见
volatile的正确使用
加锁即可以保证可见性,又可以保证原子性,但是volatile变量仅仅可以保证可见性。
只有在这几种条件下,才应该使用volatile变量:
- 对变量的写入操作不依赖变量的当前值,或者确保只有单个线程更新变量的值(比如volatile修饰变量i,但是对变量i做++操作并不是线程安全的)
- 该变量不会与其他状态状态变量一起纳入不变条件
- 在访问该变量时不需要加锁
对象的发布与逸出
发布:使对象能够在当前作用域之外的代码中使用
逸出:当某个不应该发布的对象被发布时,就称为逸出
逸出的几种情况:
-
方法返回一个private对象
-
还未完成初始化就把对象提供给外部:
-
在构造函数中未初始化完毕就this赋值
-
隐式逸出——注册监听事件
-
构造函数中运行线程
-
逸出举例
举栗一:
方法返回一个private对象
public class Example1 {
public static void main(String[] args) {
UnSafeArr unSafeArr=new UnSafeArr();
String[] arr &