在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修饰变量i,但是对变量i做++ *** 作并不是线程安全的)
- 该变量不会与其他状态状态变量一起纳入不变条件
- 在访问该变量时不需要加锁
对象的发布与逸出
逸出的几种情况:发布:使对象能够在当前作用域之外的代码中使用
逸出:当某个不应该发布的对象被发布时,就称为逸出
-
方法返回一个private对象
-
还未完成初始化就把对象提供给外部:
-
在构造函数中未初始化完毕就this赋值
-
隐式逸出——注册监听事件
-
构造函数中运行线程
-
方法返回一个private对象
public class Example1 { public static void main(String[] args) { UnSafeArr unSafeArr=new UnSafeArr(); String[] arr = unSafeArr.getArr(); arr[0]="77"; System.out.println(Arrays.toString(unSafeArr.getArr())); } } class UnSafeArr{ private String[] arr = {"11", "22", "33"}; public String[] getArr() { return arr; } }
结果:
可以看到,按照上面的方式发布arr,任何调用者都可以对这个数组进行修改。在这个栗子中,arr数组已经逸出了它所在的作用域了,这显然不是我们期望的。
举栗二:public class MultiThreadsError4 { static Point point; public static void main(String[] args) throws InterruptedException { new PointMaker().start(); Thread.sleep(10); // Thread.sleep(105); if (point != null) { System.out.println(point); } } } class Point { private final int x, y; public Point(int x, int y) throws InterruptedException { this.x = x; MultiThreadsError4.point = this; Thread.sleep(100); this.y = y; } @Override public String toString() { return x + "," + y; } } class PointMaker extends Thread { @Override public void run() { try { new Point(1, 1); } catch (InterruptedException e) { e.printStackTrace(); } } }
先执行Thread.sleep(10);会看到打印的结果是x=1,y=0,然后换成Thread.sleep(105);会看到x=1,y=1
显然,我们期望看到两次访问的值是相等的。
举栗三:public class MultiThreadsError6 { private Mapstates; public MultiThreadsError6() { new Thread(new Runnable() { @Override public void run() { states = new HashMap<>(); states.put("1", "周一"); states.put("2", "周二"); states.put("3", "周三"); states.put("4", "周四"); } }).start(); } public Map getStates() { return states; } public static void main(String[] args) throws InterruptedException { MultiThreadsError6 multiThreadsError6 = new MultiThreadsError6(); //Thread.sleep(1000); System.out.println(multiThreadsError6.getStates().get("1")); } }
运行结果
所以不要在构造函数中起线程去初始化~
共享变量的访问策略 线程封闭
当访问共享的可变数据时,通常需要使用同步,一种避免使用同步的方式就是不需要共享数据。如果仅在单线程内部访问数据,那就不需要同步。这种技术被称为线程封闭,它是实现线程安全性最简单的方式之一。
例如在Swing中,大量使用了线程封闭技术,JDBC的Connection对象也使用了线程封闭技术:在典型的服务器应用程序中,线程从连接池中获取一个Connection对象,并用它来处理请求,使用完之后再把对象还给连接池。由于大多数请求都是由单个线程采取同步的方式来处理的,并且在Connection对象返回之前,连接池不会再把它分配给其他的线程,因此这种链接管理模式在处理请求时隐含的将Connection对象封闭在线程之中。
栈封闭 在栈封闭中,只有通过局部变量才能访问对象。
线程本地存储 线程本地存储,ThreadLocalStorage。从字面上理解,就是这些变量在每个线程中都存在一份副本,每个线程之间互不干扰,互不影响。适用于全局变量进行共享,比如:将JDBC的链接保存到ThreadLocal对象中,每个对象都会有自己的链接;上下文等场景。
不变模式 如果某个对象在创建之后,它的状态就不能被修改,那么这个对象就被称为不可变对象。不可变对象一定是线程安全的。
满足以下条件的对象才是不可变的:
对象创建后其状态就不能被修改
对象的所有域都是final类型
对象是正确创建的(对象创建期间,this引用没有逸出)
CAS CAS,乐观锁,它虽然被叫做锁,但是它的实现过程中并没有真正的去加锁。它会记录下首次访问时对象的值,然后对它 *** 作后去比较对象现在的值,如果现在的值=首次访问的值,那么就认为线程在 *** 作期间,没有别的线程对它进行修改。当然,CAS的思想会有ABA问题,解决ABA问题的方法之一就是版本号,共享变量都维护一个版本号,每次 *** 作就+1,这样就可以有效的防止ABA问题。(这也是Mysql的Innodb存储引擎的并发控制方案MVCC的设计思想)
CopyonWrite 这种思想的核心是如果多个线程同时读取一份共享变量,那没关系,随便读取。如果有一个线程要对它进行修改,那么这个线程会对这个变量进行拷贝;然后在它的拷贝对象上进行 *** 作, *** 作期间,其余线程看到的仍然是原对象,直到这个线程对它修改完毕才会写会主内存。
这种思想适用于对共享变量的值准确性要求没那么高,允许读取过期的数据的情况。
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)