多线程-volatile关键字和ThreadLocal

时间:2021-12-09 17:03:40

1、并发编程中的三个概念

原子性:一个或多个操作。要么全部执行完成并且执行过程不会被打断,要么不执行。最常见的例子:i++/i--操作。不是原子性操作,如果不做好同步性就容易造成线程安全问题。

可见性:多个线程访问同一个变量,一个线程改变了这个变量的值,其他线程可以立即看到修改的值。可见性的问题,有两种方式保证。一是volatile关键字,二是通过synchronized和lock。详细在后面。

有序性:程序执行的顺序按照代码的先后顺序执行。

要了解有序性需要了解一下指令重排序。处理器为了提供运行效率,会将代码优化,不保证各个语句的执行顺序,但会保证执行结果跟代码顺序执行一致,其不影响单线程的执行结果,但会影响线程并发执行的正确性。指令重排序会考虑指令之间的数据依赖性,如果一个指令B必须用到指令A的结果,那么处理器会保证A在B之前执行。

要保证并发程序正确的执行,必须要保证原子性、可见性及有序性。只要有一个没有被保证,就可能导致程序运行不正确。


 2、Java内存模型

Java内存模型规定:所有变量存在主内存,每个线程有自己的工作内存。线程对变量的操作必须在工作内存进行,而不能直接对主内存进行操作。并且每个线程不能访问其他线程的工作内存。

JAVA语言本身提供的对原子性、可见性及有序性的保证:

原子性:java中,对于引用变量,和大部分的原始数据类型的读写(除long 和 double外)操作都是原子的。这些操作不可被中断,要么执行,要么不执行。对于所有被声明为volatile的变量的读写,都是原子的(除long和double外)

可见性:java提供了volatile关键字来保证可见性。

当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主内存。其他线程读取时会从内存中读到新值。普通的共享变量不能保证可见性,其被写入内存的时机不确定。当其他线程去读,可能读到的是旧的值。
另外通过synchronized和lock也可以保证可见性。它们能保证同一时刻只有一个线程获取锁然后执行同步代码。并在释放锁之前对变量的修改刷新到住内存中。以此来保证可见性

有序性:java内存模型中,允许编译器和处理器对指令进行重排序。其会影响多线程并发执行的正确性。在java里可以通过volatile关键字,还有synchronized和lock来保证有序性。

synchronized和lock保证每个时刻只有一个线程执行同步代码,使得线程串行化执行同步代码,保证了有序性。volatile如何保证的讲解在后面。


 3、volatile关键字详解

  一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰后,就具备了两层语义:保证了不同线程对这个变量进行操作时的可见性禁止了指令重排序。

  关于volatile保证可见性的原因我们上面已经讲过了,现在来看看volatile通过禁止指令重排序来保证一定的有序性的意思:

  1、当程序执行到volatile变量的读操作或写操作时,在其之前的操作的更改肯定全部已经进行,且结果对后面的操作可见。其后面的操作肯定还没有进行

  2、在进行指令优化时,不能将在volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放在其前面执行。

一个拓展:详情会在明年开始学习JVM时进行补充。
volatile关键字的原理和实现机制: 在加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令相当于一个内存屏障,其提供三个功能。   
1、它会强制将对缓存的修改操作立即写入主内存。   2、如果是写操作,它会导致其他CPU中对应的缓存行无效   3
、它确保指定重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面。即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

volatile关键字能保证可见性和一定的有序性,那它能保证对变量的操作是原子性吗?

答案是不能的。如常见的自增操作是不具备原子性的,它包括读取变量的原始值,进行加一操作,写入工作内存三个子操作。这就导致进行自增时可能发生子操作被分割执行。

如一个某个时刻变量i的值是10。线程A对i进行自增操作,在读取i的原始值后被阻塞,然后线程B对i进行自增,去读取i的原始值。由于A没有对i进行修改,所以B在主内存中读取到的是原始值并进行加1。然后把11写入主内存。然后A对i进行操作。由于已经读取了i的值,此时A的工作内存中i的值还是10,A对i进行自增加一后,把11写入主内存。两个线程分别进行了一次自增操作,但是结果却是11。

要注意的是:volatile无法保证对变量的任何操作都是原子性的。

使用volatile关键字时必须具备两个条件:

  1、对变量的写操作不依赖于当前值。

  2、该变量没有包含在具有其他变量的不变式中

即保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。


 4、ThreadLocal

  ThreadLocal,线程本地变量也可以叫线程本地存储。其为每一个线程维护了一个独立的变量副本。将对象的可见范围限制在同一个线程内。

  ThreadLoca类中提供了几个常用方法:

public T get() { }---获取ThreadLocal在当前线程中保存的变量副本
public void set(T value) { }---设置当前线程中变量的副本
public void remove() { }---移除当前线程中变量的副本
protected T initialValue() { }---protected修饰的方法。ThreadLocal提供的只是一个浅拷贝,如果变量是一个引用类型,那么就要重写该函数来实现深拷贝。建议在使用ThreadLocal一开始时就重写该函数

 

  ThreadLocal与synchronized这样的所机制不同,ThreadLocal是为了解决同一个变量如何不被多个线程共享,而锁更强调如果同步多个线程去正确共享一个变量。从开销上讲,锁机制是用时间换空间,ThreadLocal是用空间换时间。

  ThreadLocal中含有一个叫ThreadLocalMap的内部类,该类为一个采用了线性探测法实现的HashMap,它的key为ThreadLocal对象。ThreadLocalMap正是用来存储变量副本的。ThreadLocal中只含有三个成员变量,都与ThreadLocalMap的hash策略相关。唯一的threadLocalHashCode是用来寻址的hashCode。

  1、要获得当前线程私有的变量副本时需调用get()函数,它会先调用getMap()去获取当前线程的ThreadLocalMap,这个函数需要接收当前线程的实例作为参数。如果得到的ThreadLocalMap为null,那么就调用setInitialValue()函数来进行初始化,如果不为null,就通过map来获取变量副本并返回。

多线程-volatile关键字和ThreadLocal多线程-volatile关键字和ThreadLocal
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();
    }
View Code

     2、setInitialValue()函数会先调用initialValue()函数来生成初始值,该函数默认返回null。我们可以通过重写这个函数来返回我们想要在ThreadLocal中维护的变量。之后调getMap()函数去获得ThreadLocalMap。如果该map已经存在,那么就用新获得的value去覆盖旧值。否则调用createMap()创建新的map。

   private T setInitialValue() {
       T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

 set()和remove()函数都是通过getMap()来获得ThreadLocalMap然后对其进行操作

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
 }
public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
}

 

getMap()与createMap()函数实现如下,我们可以观察到ThreadLocalMap是存放在Thread中的

 ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
}

 

ThreadLocal的设计初衷就是为了避免多个线程去并发访问同一个对象,尽管它是线程安全的。因此如果用普遍的方法,通过一个全局的线程安全的map来存储多个线程的变量副本就违背了ThreadLocal的本意。在每个Thread中存放与它关联的ThreadLocalMap是完全符合其设计思想的。当想对线程局部变量进行操作时,只要把Thread作为key来获取Thread中的ThreadLocalMap即可。这种设计相比采用一个全局map的方法会占用很多内存空间,但其不需要额外采取锁等线程同步方法而节省了时间上的消耗。


ThreadLocal中的内存泄露问题:

  如果ThreadLocal被设置为null后,并且没有任何强引用指向它,根据垃圾回收的可达性分析算法,ThreadLocal将被回收。这样的话,ThreadLocalMap中就会含有key为null的Entry,而且ThreadLocalMap是在Thread中的,只要线程迟迟不结束,这些无法访问到的value就会形成内存泄露。为了解决这个问题,ThreadLocalMap中的getEntry()、set()和remove()函数都会清理key为null的Entry,以下面的getEntry()函数为例。

private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;

            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
}

 

  要注意的是ThreadLocalMap的key是一个弱引用。在这里我们分析一下强引用key和弱引用key的差别

  强引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的强引用,如果不手动删除,那么ThreadLocal将不会回收,产生内存泄漏。

  弱引用key:ThreadLocal被设置为null,由于ThreadLocalMap持有ThreadLocal的弱引用,即便不手动删除,ThreadLocal仍会被回收,ThreadLocalMap在之后调用set()、getEntry()和remove()函数时会清除所有key为null的Entry。

  ThreadLocalMap仅仅含有这些被动措施来补救内存泄露问题,如果在之后没有调用ThreadLocalMap的set()、getEntry()和remove()函数的话,那么仍然会存在内存泄漏问题。在使用线程池的情况下,如果不及时进行清理,内存泄漏问题事小,甚至还会产生程序逻辑上的问题。所以,为了安全地使用ThreadLocal,必须要像每次使用完锁就解锁一样,在每次使用完ThreadLocal后都要调用remove()来清理无用的Entry。