五分钟带你玩转多线程(五)volatile、ThreadLocal的使用场景和原理

举报
小鲍侃java 发表于 2021/09/10 00:07:10 2021/09/10
【摘要】 并发编程中的三个概念 原子性 一个或多个操作。要么全部执行完成并且执行过程不会被打断,要么不执行。最常见的例子:i++/i--操作。不是原子性操作,如果不做好同步性就容易造成线程安全问题。 可见性 多个线程访问同一个变量,一个线程改变了这个变量的值,其他线程可以立即看到修改的值。可见性的问题,有两种方式保证。一是volatile关键...

并发编程中的三个概念

  • 原子性

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

  • 可见性

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

  • 有序性

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

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

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

Java内存模型

Java内存模型规定:所有变量存在主内存,每个线程有自己的工作内存。
线程对变量的操作必须在工作内存进行,而不能直接对主内存进行操作。并且每个线程不能访问其他线程的工作内存。
JAVA语言本身提供的对原子性、可见性及有序性的保证:

 

  • 原子性:java中,对于引用变量,和大部分的原始数据类型的读写(除long 和 double外)操作都是原子的。这些操作不可被中断,要么执行,要么不执行。对于所有被声明为volatile的变量的读写,都是原子的(除long和double外)
  • 可见性:java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被更新到主内存。其他线程读取时会从内存中读到新值。普通的共享变量不能保证可见性,其被写入内存的时机不确定。当其他线程去读,可能读到的是旧的值。另外通过synchronized和lock也可以保证可见性。它们能保证同一时刻只有一个线程获取锁然后执行同步代码。并在释放锁之前对变量的修改刷新到住内存中。以此来保证可见性
  • 有序性:java内存模型中,允许编译器和处理器 对指令进行重排序。其会影响多线程并发执行的正确性。在java里可以通过volatile关键字,还有synchronized和lock来保证有序性。
     

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

volatile

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

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

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

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

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关键字的程序在并发时能够正确执行。
 

ThreadLocal

首先ThreadLocal 是一个线程的局部变量(其实就是一个Map),ThreadLocal会为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,将对象的可见范围限制在同一个线程内,而不会影响其它线程所对应的副本。
这样做其实就是以空间换时间的方式(与synchronized相反),以耗费内存为代价,单大大减少了线程同步(如synchronized)所带来性能消耗以及减少了线程并发控制的复杂度。

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

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

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

Synchronized却正好相反,它用于在多个线程间通信时能够获得数据共享。即Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离。所以ThreadLocal并不能代替synchronized,Synchronized的功能范围更广(同步机制)。
 

ThreadLocal中的内存泄露问题

如果ThreadLocal被设置为null后,并且没有任何强引用指向它,根据垃圾回收的可达性分析算法,ThreadLocal将被回收。这样的话,ThreadLocalMap中就会含有key为null的Entry,而且ThreadLocalMap是在Thread中的,只要线程迟迟不结束,这些无法访问到的value就会形成内存泄露。为了解决这个问题,ThreadLocalMap中的getEntry()、set()和remove()函数都会清理key为null的Entry,以下面的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. }
  9. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  10. Entry[] tab = table;
  11. int len = tab.length;
  12. while (e != null) {
  13. ThreadLocal<?> k = e.get();
  14. if (k == key)
  15. return e;
  16. if (k == null)
  17. expungeStaleEntry(i);
  18. else
  19. i = nextIndex(i, len);
  20. e = tab[i];
  21. }
  22. }

要注意的是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。
 

总结:

1.threadLocal是用于解决多线程共享类的成员变量,原理:在每个线程中都存有一个本地ThreadMap,相当于存了一个对象的副本,key为threadlocal对象本身,value为需要存储的对象值,这样各个线程之间对于某个成员变量都有自己的副本,不会冲突。用空间去换时间

2.使用volatile关键字的时候,该变量一旦被修改,会立即写入到主存中,同时会让其他线程的工作内存中的缓存失效,这样,其他线程在访问该变量的时候会重新从主存中读取可以获得该变量最新的数据,从而保证的变量的可见性。而volatile首先保证前面的任务都完成,保证后面的任务在现有任务之后。

3. volatile只能用于原子性的操作。如:i++,i=x;这种都不属于原子性的操作,i++有三个步骤,先读取内存中i的值,然后执行i+1操作,然后把结果写回i,这样的操作不属于原子性的。

 

文章来源: baocl.blog.csdn.net,作者:小黄鸡1992,版权归原作者所有,如需转载,请联系作者。

原文链接:baocl.blog.csdn.net/article/details/103594998

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。