JAVA线程安全性之voliate

字节探险家说
• 阅读 1537

前面我们已经简单分析过导致JAVA线程安全问题的原因,其实最主要的就两条:

  1. 多线程同时访问共享数据。
  2. 多线程访问该共享数据的过程中使用的计算方法不具备原子性。

对应的,解决线程安全问题的方案总结起来也有两条:

  1. 避免共享数据。
  2. 确保对共享数据的并发访问安全性。

避免数据共享

非常自然的我们能够想到第一条,如果我们能够避免共享数据的话,每一条线程都使用各自的数据、不访问共享数据,那么一定就不会存在线程安全问题。

我们知道JAVA虚拟机在内存管理过程中将内存划分为不同区域,其中类成员变量存储在堆内存,方法变量存储在栈内存。堆内存在不同线程之间共享数据,有线程安全问题,而栈内存是线程独占的内存,不存在线程安全线问题。

所以,允许的情况下,不使用成员变量、而是用方法变量、临时变量的话,可以避免共享数据,从而确保数据的线程安全问题。

比如以下代码,多线程并发的情况下,counter有线程安全问题,而变量j是线程安全的、没有线程安全问题。

public class Account {
        private int counter=0;
        public void doAddCounter(){
            for(int j=0;j<100;j++){
                counter++;
            }
        }

        public int getCounter(){
            return counter;
    }
}

确保对共享数据的并发访问安全性

然而实际上我们很少有利用局部变量代替成员变量从而避免线程安全的机会,因为成员变量有他存在的理由和价值,为了避免线程安全问题而减少成员变量的使用,就是因噎废食,代码一定会丑陋不堪。

JAVA为我们提供了不同的线程安全问题解决方案,我们可以根据不同的场景采用不同的方案。包括voliate、synchronized关键字,以及ThreadLocal类,等等。

这篇文章首先分析一下voliate。

voliate

voliate是轻量级同步同步机制,可以确保:

  1. 内存可见性
  2. 避免指令重排

以上两条是JAVA虚拟机在处理voliate关键字的时候的基本原则,但是一般情况下,以上两条解释对你深入理解线程安全问题并没有什么鸟帮助,想要彻底理解voliate,你必须要进行进一步的剖析。

什么是轻量级同步机制

首先,轻量级同步机制是和synchronized相比较而言的,由于synchronized的实现依赖于操作系统的线程管理机制,需要更多的系统资源调度才能实现,所以我们一般管他叫做重量级实现。相比而言,voliate是在JAVA世界的内部实现,是JAVA虚拟机内部自己就能解决的,所以我们管voliate叫轻量级同步机制。

内存可见性

理解voliate确保“内存可见性”,需要对JAVA内存模型JMM(JAVA Memery Module)做一个简单的了解,记住,我们带着明确的目标去了解JMM,现在我们这个明确的目标是理解voliate的“内存可见性”的具体含义,所以我们不扩展不偏移目标,我们不是要了解整个JMM世界。

好了,我们带着这个明确的目标来了解一下JMM:JAVA内存模型约定,JAVA的内存分为主内存和工作内存,JAVA线程只能访问工作内存,各条线程都有自己的工作内存,而工作内存的数据均来源于主内存,JAVA线程从工作内存获取到数据、并对数据操作之后,必须将数据写回主内存才能使得操作生效。

多线程访问共享数据时,根据JMM的约定,共享数据存放在主内存,各线程访问时首先从主内存读取数据到自己的工作内存,然后在自己的工作内存区对数据进行操作(比如+1),操作完成后再从自己的工作内存写入到主内存(+1后的值)以使得线程对该变量的操作生效。

所以对于普通变量(指的是没有被voliate修饰的变量),假设有两条线程A和B并发执行,线程A和线程B同时将该变量从主内存读取到自己的工作内存,这时线程A和线程B获取到相同的初始数据。假设线程A先执行,该变量+1后被写回主内存。这个写回数据到主内存的动作线程B并不知道。

接下来线程B获得执行权,线程B对该变量+1后,再写回主内存。此时线程B其实就覆盖掉了线程A的操作,从而引发了线程安全问题。

如果变量加了voliate关键字,JMM会解决上述案例中线程A对该共享变量执行+1操作后的“线程B”并不知道的问题,voliate确保线程A对变量修改后,所有其他线程对该修改立即可见,也就是,线程B也知道了该变量的新值,从而可以在新值的基础上进行操作,也就避免了线程安全问题。

指令重排

这个问题比较简单,一般来讲,出于性能考虑,JVM并不是完全按照我们代码的顺序生成机器码的,他会判断在不影响程序逻辑的基础上调整我们代码的顺序,我们一般把这个顺序调整称为指令重排。

然而,指令重排虽然不会影响单线程应用的执行结果,但是在多线程并发环境下,指令重排有可能会导致线程安全问题。

voliate关键字会避免指令重排,因此,从指令重排的角度,可以避免线程安全问题。

voliate是否会彻底避免线程安全问题?

根据以上分析,我们猜测的答案应该是:voliate可以彻底避免线程安全问题。

然而,答案是:这个猜测是错误的。

这个答案很让人费解,但是这个答案是对的,你可以很容易的通过测试进行验证,但是解释起来却比较麻烦。

这又涉及到一个操作原子性的问题,原子性的操作一口气完成,不允许其他线程中断,而非原子性的操作却无法保证这一点,操作的过程中可能会被其他线程中断。

比如我们上面的例子,counter++的这个++操作,就不是原子性的,操作系统底层在执行++操作的时候首先会将counter变量的值从内存(此时你可以理解为工作内存)读入到CPU寄存器,然后再进行+1操作,之后再从寄存器写回到工作内存。这3个步骤的任何一步都有可能被中断。

我们尝试举例解释一下volicate无法确保线程安全性的问题:counter是voliate变量,线程ABC并发,假设线程A首先完成了counter++的操作,这个时候voliate确保该修改写回主内存后立即被线程BC获取到,这个时候线程安全问题没有发生,一切正常。此时假设线程BC并发执行,线程B的++操作被线程C的++操作中断,随后BC同时完成了++操作,当他们将操作后的counter值写回主内存时,线程安全问题发生!

JAVA内存模型、JAVA内存区域以及线程安全问题是比较底层比较复杂的问题,以上仅是个人的理解,决不能排除理解错误。程序员应该把自己当知识分子对待,持续学习,后续如有新发现否定本人此时的认识,必将即时更正。

点赞
收藏
评论区
推荐文章
Wesley13 Wesley13
3年前
java 面试知识点笔记(十)多线程与并发
问:线程安全问题的主要诱因?1.存在共享数据(也称临界资源)2.存在多条线程共同操作这些共享数据解决方法:同一时刻有且只有一个线程在操作共享数据,其他线程必须等到该线程处理完数据后再对共享数据进行操作互斥锁的特征:1.互斥性:即在同一时间只允许一个线程持有某个对象锁,通过这种特性来实现多线程协调机制,这样在同一时间只有一
Wesley13 Wesley13
3年前
Java 并发数据结构
\TOCM\因为Java提供了一些非线程安全的数据结构如HashMap,ArrayList,HashSet等。所有在多线程环境中需要使用支持并发访问操作的数据结构。并发ListVector,CopyOnWriteArrayList是线程安全的List。ArrayList是线程不安全的。如果一定要使用,需要:Collection
Wesley13 Wesley13
3年前
JAVA 并发包
Java.Utril.ConcurrentVolatile关键字避免java虚拟机指令重排序,保证共享数据修改同步,数据可见性。volatile相较于synchronized是一种比较轻量级地同步策略,但不具备互斥性,不能成为synchronized的替代,不能保证原子性。
Wesley13 Wesley13
3年前
Java线程知识深入解析(2)
多线程程序对于多线程的好处这就不多说了。但是,它同样也带来了某些新的麻烦。只要在设计程序时特别小心留意,克服这些麻烦并不算太困难。(1)同步线程许多线程在执行中必须考虑与其他线程之间共享数据或协调执行状态。这就需要同步机制。在Java中每个对象都有一把锁与之对应。但Java不提供单独的lock和unlock操作。它由高层的结构隐
Wesley13 Wesley13
3年前
Java多线程优化
\以下文章来源于51CTO技术栈 ,作者崔皓今天,我们从Java内部锁优化,代码中的锁优化,以及线程池优化几个方面展开讨论。Java 内部锁优化当使用Java多线程访问共享资源的时候,会出现竞态的现象。即随着时间的变化,多线程“写”共享资源的最终结果会有所不同。为了解决这个问题,让多线程“写”资源的时候有先后顺序,引入
Wesley13 Wesley13
3年前
Java thread run() start() 是干什么的以及区别
Java thread run() start()是干什么的?为什么一调他们就开始运行里面的方法了?以及区别?1.这个属于线程的同步机制问题,也就是线程安全问题,实际开发中用到多线程的例子很多,比如说:银行排号、火车站买票等,就是很多机器同时访问共享数据的时候就是这个了。2.线程启动之后(被调之后),会运行被覆盖的run方
Stella981 Stella981
3年前
Linux并发与同步专题
并发访问:多个内核路径同时访问和操作数据,就有可能发生相互覆盖共享数据的情况,造成被访问数据的不一致。临界区:访问和操作共享数据的代码段。并发源:访问临界区的执行线程或代码路径。在内核中产生并发访问的主要有如下4种:中断和异常:中断发生后,中断处理程序和被中断的进程之间有可能产生并发访问。中断<被中断的线程软中断和ta
Wesley13 Wesley13
3年前
Java中的锁原理、锁优化、CAS、AQS,看这篇就对了!
01为什么要用锁?锁是为了解决并发操作引起的脏读、数据不一致的问题。02 锁实现的基本原理2.1、volatileJava编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些
Wesley13 Wesley13
3年前
Java 并发编程:AQS 的互斥锁与共享锁
我们知道现代机器处理器几乎都是多核多线程的,引入多核多线程机制是为了尽可能提升机器整体处理性能。但是多核多线程也会带来很多并发问题,其中很重要的一个问题是数据竞争,数据竞争即多个线程同时访问共享数据而导致了数据冲突(不正确)。数据竞争如果没处理好则意味着整个业务逻辑可能出错,所以在高并发环境中我们要特别注意这点。!(https://pic2.zhim
Wesley13 Wesley13
3年前
Java多线程——线程封闭
线程封闭:当访问共享的可变数据时,通常需要同步。一种避免同步的方式就是不共享数据。如果仅在单线程内访问数据,就不需要同步,这种技术称为线程封闭(thread confinement)  线程封闭技术一个常见的应用就是JDBC的Connection对象,JDBC规范并没有要求Connection对象必须是线程安全的,在服务器应用程序中,线程从连接