非常感谢洋哥的本周知识分享,灰常精辟~!洋哥的知识串起来了线程安全的大部分知识,我也根据我的知识储备及网络搜寻,整理了一份我自己当前的理解。
一. 线程安全性的知识准备
1.1 知识准备a:JVM 内存模型 与 线程安全
线程安全,就是通过多个线程对某个资源进行有序访问或者修改,这里的某项资源对应的底层即是一个个的 JVM 内存模型。
所以,针对 线程安全 来谈的 JVM 内存模型,想要实现线程安全,那么就要实现两点:可见性及有序性,前者保证某项线程修改某共享变量之后可以被其它线程感知,后者保证非原子操作的线程安全。
什么是 可见性?
普通情况下,当线程需要对某一共享变量进行修改时,通常会进行如下的过程:
a 从主内存中拷贝变量的一份副本,并装载到工作内存中;
b 在工作内存中执行代码,修改副本的值;
c 用工作内存中的副本值更新主存中的相关变量值。
当一个共享变量在多个线程的工作内存中都有副本时,如果一个线程修改了这个共享变量,那么其他线程应该能够看到这个被修改后的值,这就是多线程的可见性问题。
什么是 有序性?
非原子操作时,由于各线程的操作都是针对 Work-Memory 的,如果多个线程时无序操作的,那么很有可能出现在 a,b 线程同时读取一项共享数据,然后分别做了修改,这样就会出现线程安全问题,举例如下:
假设有一个共享变量x,线程a执行x=x+1。从上面的描述中可以知道x=x+1并不是一个原子操作,它的执行过程如下:
1 从主存中读取变量x副本到工作内存
2 给x加1
3 将x加1后的值写回主 存
如果另外一个线程b执行x=x-1,执行过程如下:
1 从主存中读取变量x副本到工作内存
2 给x减1
3 将x减1后的值写回主存
那么显然,最终的x的值是不可靠的。假设x现在为10,线程a加1,线程b减1,从表面上看,似乎最终x还是为10,但是多线程情况下会有这种情况发生:
1:线程a从主存读取x副本到工作内存,工作内存中x值为10
2:线程b从主存读取x副本到工作内存,工作内存中x值为10
3:线程a将工作内存中x加1,工作内存中x值为11
4:线程a将x提交主存中,主存中x为11
5:线程b将工作内存中x值减1,工作内存中x值为9
6:线程b将x提交到中主存中,主存中x为9
同样,x有可能为11,如果x是一个银行账户,线程a存款,线程b扣款,显然这样是有严重问题
1.2 volatile 关键字
volatile是java提供的一种轻量级同步,该关键字只能保证 JVM 内存模型的可见性,volatile共享变量进行写操作的时候,会多出一条lock前缀的指令,即告知JVM:它所修饰的域的原子操作都不需要经过线程的工作内存,而直接在主内存中进行修改。这样就保证了线程从主内存中读取(read)它的值的时候,总是最新的,同时另一方面,volatile 并不能保证有序性。
但是问题在于,Java 中原子操作较少,volatile 只能保证赋值操作(原子操作)的安全性,而不能保证非原子操作的安全性。
补充:lock 操作的底层含义
lock指令在多核处理器下会引发两件事:
1. 将当前处理器缓存行(cache line)的数据写会到系统内存
2. 这个写会内存操作会使其它CPU缓存了该内存地址的数据无效
缓冲行:缓存中可以分配最小存储单位。
为了提高处理速度,处理器不直接与内存进行通信。而是先将系统内存的数据读到内部缓存(L1,L2或者其他)后再做操作。但操作完不知道何时回写到内存。
如果对生命了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算就会有问题。所以,在多处理器下,为了保证各个处理器缓存的值是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存。
lock
二. 线程安全性基础
2.1 什么样的数据会出现线程安全问题?
a. 共享的
b. 可变的
补充知识:线程安全性有哪几类?
Bloch 给出了描述五类线程安全性的分类方法:不可变、线程安全、有条件线程安全、线程兼容和线程对立。
1. 不可变
不可变的对象一定是线程安全的,并且永远也不需要额外的同步[1] 。因为一个不可变的对象只要构建正确,其外部可见状态永远也不会改变,永远也不会看到它处于不一致的状态。
Java 类库中大多数基本数值类如 Integer 、 String 和 BigInteger 都是不可变的。
需要注意的是,对于Integer,该类不提供add方法,加法是使用+来直接操作。而+操作是不具线程安全的。这是提供原子操作类AtomicInteger的原原因。
2. 线程安全
线程安全的对象具有在上面“线程安全”一节中描述的属性;
由类的规格说明所规定的约束在对象被多个线程访问时仍然有效,不管运行时环境如何排线程都不需要任何额外的同步。这种线程安全性保证是很严格的;
许多类,如 Hashtable 或者 Vector 都不能满足这种严格的定义。
3. 有条件的
有条件的线程安全类对于单独的操作可以是线程安全的,但是某些操作序列可能需要外部同步。
条件线程安全的最常见的例子是遍历由 Hashtable 或者 Vector 或者返回的迭代器,由这些类返回的 fail-fast 迭代器假定在迭代器进行遍历的时候底层集合不会有变化。
为了保证其他线程不会在遍历的时候改变集合,进行迭代的线程应该确保它是独占性地访问集合以实现遍历的完整性。通常,独占性的访问是由对锁的同步保证的,
并且类的文档应该说明是哪个锁(通常是对象的内部监视器(intrinsic monitor))。
如果对一个有条件线程安全类进行记录,那么您应该不仅要记录它是有条件线程安全的,而且还要记录必须防止哪些操作序列的并发访问。
用户可以合理地假设其他操作序列不需要任何额外的同步。
4. 线程兼容
线程兼容类不是线程安全的,但是可以通过正确使用同步而在并发环境中安全地使用。
这可能意味着用一个 synchronized 块包围每一个方法调用,或者创建一个包装器对象,其中每一个方法都是同步的(就像 Collections.synchronizedList() 一样)。
也可能意味着用 synchronized 块包围某些操作序列。为了最大程度地利用线程兼容类,如果所有调用都使用同一个块,那么就不应该要求调用者对该块同步。
这样做会使线程兼容的对象作为变量实例包含在其他线程安全的对象中,从而可以利用其所有者对象的同步。
许多常见的类是线程兼容的,如集合类 ArrayList 和 HashMap 、 java.text.SimpleDateFormat 、或者 JDBC 类 Connection 和 ResultSet 。
5. 线程对立
线程对立类是那些不管是否调用了外部同步都不能在并发使用时安全地呈现的类。
线程对立很少见,当类修改静态数据,而静态数据会影响在其他线程中执行的其他类的行为,这时通常会出现线程对立。
线程对立类的一个例子是调用 System.setOut() 的类
2.2 如何避免线程安全问题?
1. 不可变(有final关键字修饰且已被赋值)
2. 不共享(局部变量,ThreadLocal等)
3. 加锁(synchronized、lock、ReentrantLock等)
三. TreadLocal 定义及使用
网上查了一部分关于 Threadlocal 的资料,主要是从 线程安全 解析的居多,但按照作者的原意,ThreadLocal 类的设计不仅用于多线程环境下的各线程维护独立于其它线程之外的的变量,同时也能用于关联线程的上下文。
This class provides thread-local variables. These variables differ from
their normal counterparts in that each thread that accesses one (via its
{@code get} or {@code set} method) has its own, independently initialized
copy of the variable. {@code ThreadLocal} instances are typically private
static fields in classes that wish to associate state with a thread (e.g.,
a user ID or Transaction ID).
根据winwill2012的中文翻译:ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。
其实再翻译下也就是说:某个不便于一直传递的,同时是该线程所特有的资源,该资源就可以放置在 ThreadLocal 中。
3.1 实现原理
每个 Thread 维护一个 ThreadLocalMap 映射表,这个映射表的 key 是 ThreadLocal 实例本身,value 是真正需要存储的 Object。
JDK 1.8 中的 get 方法源码:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
get
getMap 方法的源码:
1 ThreadLocalMap getMap(Thread t) {
2 return t.threadLocals;
3 }
getMap
setInitialValue函数的源码:
1 private T setInitialValue() {
2 T value = initialValue();
3 Thread t = Thread.currentThread();
4 ThreadLocalMap map = getMap(t);
5 if (map != null)
6 map.set(this, value);
7 else
8 createMap(t, value);
9 return value;
10 }
setInitialValue
createMap函数的源码:
1 void createMap(Thread t, T firstValue) {
2 t.threadLocals = new ThreadLocalMap(this, firstValue);
3 }
createMap
从以上的代码中,我们可以大致总结出 get 方法的流程:
1. 获取当前线程;
2. 根据当前线程获取一个 Map;
3. 如果 Map 不为空,根据当前线程 ThreadLocal 的饮用作为 key ,获取 entry e;
4. 如果 e 不为空,返回 e.value,否则转到5;
5. Map 为空或者 e 为空,则通过 initialValue 函数获取初始值 value,然后用 ThreadLocal 的引用和 value 作为 firstKey 和 firstValue 创建一个新的 Map
3.2 ThreadLocalMap 的回收问题
补充知识:Java 中的强引用,软引用,弱引用,虚引用
1.强引用
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空 间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
2.软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可物的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可物的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解。
被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在实际程序设计中一般很少使用弱引用与虚引用,使用软用的情况较多,这是因为软引用可以加速JVM对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
软引用代码示例:
import java.lang.ref.SoftReference;
public class Test {
public static void main(String[] args){
System.out.println("开始");
A a = new A();
SoftReference<A> sr = new SoftReference<A>(a);
a = null;
if(sr!=null){
a = sr.get();
}
else{
a = new A();
sr = new SoftReference<A>(a);
}
System.out.println("结束");
}
}
class A{
int[] a ;
public A(){
a = new int[100000000];
}
}
四种引用
而在 ThreadLocal 中,ThreadLocalMap是使用ThreadLocal的弱引用作为Key的:
1 static class ThreadLocalMap {
2 /**
3 * The entries in this hash map extend WeakReference, using
4 * its main ref field as the key (which is always a
5 * ThreadLocal object). Note that null keys (i.e. entry.get()
6 * == null) mean that the key is no longer referenced, so the
7 * entry can be expunged from table. Such entries are referred to
8 * as "stale entries" in the code that follows.
9 */
10 static class Entry extends WeakReference<ThreadLocal<?>> {
11 /** The value associated with this ThreadLocal. */
12 Object value;
13 Entry(ThreadLocal<?> k, Object v) {
14 super(k);
15 value = v;
16 }
17 }
18 ...
19 ...
20 }
ThreadLocalMap
用图例表示为(实线为强引用,虚线为弱引用):
由图可知,如果不做任何处理,可能会发生以下情况:
ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用引用他,那么系统 gc 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value 永远无法回收,造成内存泄露。
所以为了避免这种情况,在设计中已经做了一些防护措施:
ThreadLocalMap的getEntry方法的源码:
1 private Entry getEntry(ThreadLocal<?> key) {
2 int i = key.threadLocalHashCode & (table.length - 1);
3 Entry e = table[i];
4 if (e != null && e.get() == key)
5 return e;
6 else
7 return getEntryAfterMiss(key, i, e);
8 }
getEntry
getEntryAfterMiss函数的源码:
1 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
2 Entry[] tab = table;
3 int len = tab.length;
4 while (e != null) {
5 ThreadLocal<?> k = e.get();
6 if (k == key)
7 return e;
8 if (k == null)
9 expungeStaleEntry(i);
10 else
11 i = nextIndex(i, len);
12 e = tab[i];
13 }
14 return null;
15 }
getEntryAfterMiss
expungeStaleEntry
函数的源码:
1 private int expungeStaleEntry(int staleSlot) {
2 Entry[] tab = table;
3 int len = tab.length;
4 // expunge entry at staleSlot
5 tab[staleSlot].value = null;
6 tab[staleSlot] = null;
7 size--;
8 // Rehash until we encounter null
9 Entry e;
10 int i;
11 for (i = nextIndex(staleSlot, len);
12 (e = tab[i]) != null;
13 i = nextIndex(i, len)) {
14 ThreadLocal<?> k = e.get();
15 if (k == null) {
16 e.value = null;
17 tab[i] = null;
18 size--;
19 } else {
20 int h = k.threadLocalHashCode & (len - 1);
21 if (h != i) {
22 tab[i] = null;
23 // Unlike Knuth 6.4 Algorithm R, we must scan until
24 // null because multiple entries could have been stale.
25 while (tab[h] != null)
26 h = nextIndex(h, len);
27 tab[h] = e;
28 }
29 }
30 }
31 return i;
32 }
expungeStaleEntry
根据这些源码,我们可以分析出 getEntry 的运行流程:
1. 首先从 ThreadLocal 的直接索引位置(通过 ThreadLocal.threadLocalHashCode & (len-1) 运算得到)获取 Entry e,如果 e 不为 null 并且 key 相同则返回 e;如果 e 为 null 或者 key 不一致则向下一个位置查询,如果下一个位置的 key 和当前需要查询的 key 相等,则返回对应的 Entry,否则,如果 key 值为 null,则擦除该位置的 Entry,否则继续向下一个位置查询。
2. 在这个过程中遇到的 key 为 null 的 Entry 都会被擦除,那么 Entry 内的 value 也就没有强引用链,自然会被回收。仔细研究代码可以发现,set 操作也有类似的思想,将 key 为 null 的这些 Entry 都删除,防止内存泄露。但是光这样还是不够的,上面的设计思路依赖一个前提条件:要调用 ThreadLocalMap 的 getEntry 函数或者 set 函数。这当然是不可能任何情况都成立的,所以很多情况下需要使用者手动调用 ThreadLocal 的 remove 函数,手动删除不再需要的 ThreadLocal ,防止内存泄露。所以 JDK 建议将 ThreadLocal 变量定义成 private static 的,这样的话 ThreadLocal 的生命周期就更长,由于一直存在 ThreadLocal 的强引用,所以 ThreadLocal 也就不会被回收,也就能保证任何时候都能根据 ThreadLocal 的弱引用访问到 Entry的value 值,然后 remove 它,防止内存泄露。
3.3 ThreadLocal 总结(摘自洋哥的 PPT)
ThreadLocal 负责管理ThreadLocalMap,包括插入,删除 等等,key就是ThreadLocal对象自己;同时,很重要的一点,就ThreadLocal把map存储在当前线程对象里面。 为什么在ThreadLocalMap 中弱引用ThreadLocal对象呢?当然是从线程内存管理的角度出发的。 使用弱引用,使得ThreadLocalMap知道ThreadLocal对象是否已经失效,一旦该对象失效,也就是成为垃圾,那么它所操控的Map里的数据也就没有用处了,因为外界再也无法访问,进而决定擦除Map中相关的值对象,即:Entry对象的引用,来保证Map总是尽可能的小。 总之,线程通过ThreadLocal 来给自己的map 添加值,删除值。同时一旦ThreadLocal本身成为垃圾,Map也能自动清除该ThreadLocal所操控的数据。 这样,通过设计一个代理类ThreadLocal,保证了我们只需要往Map里面塞数据,无需担心清除,这是普通map做不到的。
四. 同步加锁的四大方式
4.1 volatile (前文已提到,此处不再赘述)
4.2 JVM 内置锁(监视器锁) synchronized
非常久远以及久经使用的锁,也被称为重量级锁,基本用法:
待续。。