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

简介: • 原子性一个或多个操作。要么全部执行完成并且执行过程不会被打断,要么不执行。最常见的例子:i++/i--操作。不是原子性操作,如果不做好同步性就容易造成线程安全问题。• 可见性多个线程访问同一个变量,一个线程改变了这个变量的值,其他线程可以立即看到修改的值。可见性的问题,有两种方式保证。一是volatile关键字,二是通过synchronized和lock。详细在后面。• 有序性

并发编程中的三个概念

  • 原子性

一个或多个操作。要么全部执行完成并且执行过程不会被打断,要么不执行。最常见的例子: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. 
13. while (e != null) {
14.                 ThreadLocal<?> k = e.get();
15. if (k == key)
16. return e;
17. if (k == null)
18.                     expungeStaleEntry(i);
19. else
20.                     i = nextIndex(i, len);
21.                 e = tab[i];
22.             }
23. }
24.

要注意的是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,这样的操作不属于原子性的。

 


相关文章
|
2月前
|
安全 Java 数据库
一天十道Java面试题----第四天(线程池复用的原理------>spring事务的实现方式原理以及隔离级别)
这篇文章是关于Java面试题的笔记,涵盖了线程池复用原理、Spring框架基础、AOP和IOC概念、Bean生命周期和作用域、单例Bean的线程安全性、Spring中使用的设计模式、以及Spring事务的实现方式和隔离级别等知识点。
|
2月前
|
编解码 网络协议 API
Netty运行原理问题之Netty的主次Reactor多线程模型工作的问题如何解决
Netty运行原理问题之Netty的主次Reactor多线程模型工作的问题如何解决
|
28天前
|
存储 缓存 Java
什么是线程池?从底层源码入手,深度解析线程池的工作原理
本文从底层源码入手,深度解析ThreadPoolExecutor底层源码,包括其核心字段、内部类和重要方法,另外对Executors工具类下的四种自带线程池源码进行解释。 阅读本文后,可以对线程池的工作原理、七大参数、生命周期、拒绝策略等内容拥有更深入的认识。
什么是线程池?从底层源码入手,深度解析线程池的工作原理
|
2天前
|
Java 编译器 程序员
【多线程】synchronized原理
【多线程】synchronized原理
13 0
|
2天前
|
缓存 Java 编译器
【多线程-从零开始-伍】volatile关键字和内存可见性问题
【多线程-从零开始-伍】volatile关键字和内存可见性问题
10 0
|
2天前
|
Java 应用服务中间件 API
nginx线程池原理
nginx线程池原理
8 0
|
18天前
|
存储 缓存 Java
JAVA并发编程系列(11)线程池底层原理架构剖析
本文详细解析了Java线程池的核心参数及其意义,包括核心线程数量(corePoolSize)、最大线程数量(maximumPoolSize)、线程空闲时间(keepAliveTime)、任务存储队列(workQueue)、线程工厂(threadFactory)及拒绝策略(handler)。此外,还介绍了四种常见的线程池:可缓存线程池(newCachedThreadPool)、定时调度线程池(newScheduledThreadPool)、单线程池(newSingleThreadExecutor)及固定长度线程池(newFixedThreadPool)。
|
2月前
|
存储 NoSQL Java
线程池的原理与C语言实现
【8月更文挑战第22天】线程池是一种多线程处理框架,通过复用预创建的线程来高效地处理大量短暂或临时任务,提升程序性能。它主要包括三部分:线程管理器、工作队列和线程。线程管理器负责创建与管理线程;工作队列存储待处理任务;线程则执行任务。当提交新任务时,线程管理器将其加入队列,并由空闲线程处理。使用线程池能减少线程创建与销毁的开销,提高响应速度,并能有效控制并发线程数量,避免资源竞争。这里还提供了一个简单的 C 语言实现示例。
|
1月前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
21 0
|
2月前
|
缓存 Java
【多线程面试题二十三】、 说说你对读写锁的了解volatile关键字有什么用?
这篇文章讨论了Java中的`volatile`关键字,解释了它如何保证变量的可见性和禁止指令重排,以及它不能保证复合操作的原子性。

热门文章

最新文章