Java多线程安全原理


从[深入理解Java虚拟机],[Java并发编程的艺术]这两本书里学到了很多知识。
在学习的过程中,总结下对多线程的理解。多线程的底层原理非常复杂,个人也在不断学习当中,这篇文章也只是管中窥豹,难免有错误的地方。

这篇文章希望能对以下几个问题有疑惑的人有所启发:

  1. 对象为什么会不安全?
  2. Java多线程之间怎么相互通信?
  3. 开发人员怎样判断对象在多线程环境下是否安全?
  4. 开发人员怎么编写正确的高并发代码?

JVM的内存区域

首先看下JVM(java虚拟机)运行时的数据区域的划分:
这里写图片描述

图中的这些区域都有各自的用途以及生命周期,简单了解下:

  • 方法区:Method Area是各个线程共享的内存区域,它用来存储被jvm加载的类信息,常量,静态变量等数据。我们经常提到的常量池就存放在这里。

  • 堆:Java Heap用来存放所有对象的实例,由所有线程共享。JVM规范中指出所有的对象实例以及对应的数组实例都在堆中分配。堆是jvm内存中最大的一块区域,也是GC(Garbage Collected)垃圾回收机制作用的主要区域。

  • 程序计数器:每一个线程都各自拥用有独立的计数器,用来存储所执行的字节码的行号。当多线程相互交替执行的时候,各自线程依靠它来选取下一条需要执行的字节码指令。(你可以把它当作一个标记,当线程被其他线程抢占了cpu,也就是阻塞的时候,会记录下当前执行到的字节码行号。当该线程重新可执行的时候,它就知道从哪开始执行了)

  • 栈:虚拟机栈是用来存储Java方法执行的区域。解释下这句话:每个方法在执行的时候都会创建一个栈帧(一种数据结构),栈帧里存储了局部变量表,对象引用,操作数栈,方法出口等信息。每一个方法从开始到执行结束的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

再看下面的代码:

public class Demo {

    public Demo() {
    }

}
/**
 * Created by xieyuhui on 2017/9/5.
 */
public class TestDemo {

    public static void main(String[] args) {
        String s = new String();
        int i = 1;
        Demo d = new Demo();
    }
}

当我们运行main方法的时候,对应的JVM内存区域是这样的:
jvm实例化对象
main方法没有逻辑,只实例化了两个变量,赋值了一个基本类型int。main方法执行的时候会被转换成一个栈帧push到虚拟机栈的栈顶(栈里面可能会有很多栈帧,只有位于栈顶的栈帧才会被线程执行)
当线程执行到String s = new String()的时候,生成一个引用变量’s’,该变量存储了指向堆内存中String对象的指针,同理引用变量’d’存储了指向Demo对象的指针。JVM规范规定所有的基本类型直接在栈上分配内存空间,称之为局部变量(下面再说到变量的时候,私有的局部变量不算在内,因为讨论它们没有意义)
如果该方法是第一次执行,因为’s’指向的String对象在构造的时候char[]被final修饰(这也是string对象不可变的原因之一),jvm会把’s’放到方法区的常量池里已备后续使用。

JVM运行原理非常复杂,也不是本篇要讨论的,知道概念就行了。


JAVA内存模型

线程之间通信主要通过两种方式(通信是指线程之间以何种机制来交换信息)

  • 共享内存:通过读-写内存中的公共区域进行隐式通信。
  • 消息传递:线程之间发送消息进行显式通信。

JAVA线程之间通信是通过共享内存方式完成的。

JMM概念图

上图是JMM(Java Memory Model)JAVA内存模型的概念结构图,虽然和上面的JVM内存区域是不同层次的内存划分,但其实可以对应上。工作内存可以对应栈,主内存可以对应堆。

工作内存与主内存的交互

线程不能自己创建变量,线程在工作内存中操作的变量全都是从主内存里copy的变量副本。JMM中规定了8种操作来完成工作内存和主内存的交互:

  • 加锁 lock:把主内存中的一个变量标识为一条线程独占的状态。

  • 解锁 unlock:把主内存中处于加锁状态的变量释放出来。

  • 读取 read:把主内存的变量的值传输到工作内存中。

  • 载入load:把主内存传输过来的变量值放进工作内存的变量副本中。

  • 使用 use:把变量副本的值传递给执行引擎进行运算操作。

  • 赋值 assign:工作内存接受执行引擎传递过来的值放进变量副本里。

  • 存储 store:把工作内存的变量副本的值传输给主内存。

  • 写入 write :把工作内存接收到的值放进主内存的变量中。

Java线程之间的通信由JMM控制,JMM决定一个线程对主内存中的共享变量的写入何时对另外的线程可见。
理想状态下的线程线程执行

  1. JMM通过read和load操作把主内存里的共享变量i的值复制到各个工作内存里。

  2. 线程A通过use和assign操作改变了i的值。

  3. 线程A通过store和write操作把工作内存中i的值同步回主内存。

  4. 线程B需要操作i的时候,会通过read和load操作重新读取主内存里的变量i,发现此时i的值已经改变了,刷新自己工作内存i的值。

这就是JAVA线程之间的通信手段,通过共享主内存隐式通信

然而上图是理想化的线程执行模型,我们认为线程B会在线程A执行完成后执行,并且认为线程A在本地内存改变了i的值后会刷新回主内存。然而现实并不是这样,Java多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在不做任何处理的情况下,我们无法保证线程之间的执行顺序。jvm也没有保证上面的8种操作是有序的。

也就是说线程A在工作内存中改变了i的值,可能还没有刷新回主内存,这时线程B抢到了执行时间,去读取主内存i变量的值。这时候程序员认为此时i的值是1,对于线程A来说是的,但对于其他线程来说,它们没有和线程A通信过,因此它们并不知道i的值已经改变了。

所以我们认为:在多线程环境下,主内存里的变量i是一个线程不安全的对象


happens-before原则

在主内存中,像i这样的共享变量都是线程不安全的,因为它们无法保证一个线程对它们的操作对于另外的线程可见。在我们日常开发中,诸如i这样的共享变量非常常见,我们也没有对它们做过任何的同步处理(比如锁),那么是不是说在多线程环境下,这些代码都有线程安全问题呢?答案是不一定~

在JMM中,存在一个”先行发生(happens-before)原则”,JMM就是依靠这个原则判断线程是否安全,数据是否存在竞争。我们也可以通过这些原则发现对象在并发环境下是否可能存在线程安全的问题。

定义:”先行并发”指定是操作之间的顺序,A”先行并发”B,那么A操作产生的影响能被B察觉到,影响指的是:修改了共享变量的值,调用了某些方法,发送了消息等。

下面是JMM中”天然存在”的先行发生原则,如果你的代码里有两个操作之间的关系从下列原则里推导不出来,那么它们就不受JMM控制,JVM会对它们随意的进行重排序,这时候就需要程序员介入,用编码的方式解决了。

  • 程序次序规则:在一个线程中的每个操作,happen-before于该线程中的任意后续操作。

  • 监视器锁定规则:对一个锁的unlock,happen-before于随后对这个锁的lock。

  • volatile变量规则:对一个volatile变量的写操作,happen-before于任意后续对这个对象的读操作。

  • 线程启动规则:Thread对象的start()方法,happen-before于此线程的所有操作。

  • 线程终止规则:线程所有操作,happen-before于Thread对象的join()方法。

  • 线程中断规则:对线程interrupt()方法的调用,happen-before于被中断线程的代码检测到中断事件的发生。

  • 对象终结规则:一个对象的初始化完成,happen-before于它的finalized()方法。

  • 传递性:如果A happen-before B,且B happen-before C,那么A happen-before C。

上面就是Java语言无须任何同步手段保障就能成立的先行发生规则。这些规则随便拿出一条来介绍都能独立出一篇博客文章。你可能看的很糊涂,我们来举个例子:

/**
 * Created by xieyuhui on 2017/7/17.
 */
public class TestDemo {

    private int value = 0;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}

TestDemo类非常简单,有一组getter/setter方法。假设线程A先执行,调用了”setValue(1)”方法,然后线程B调用”getValue()”,这时候线程B返回值是1吗?

就这个例子我们套用上面的各个先行发生原则分析一下:

  • 程序次序规则:set()和get()方法分别由两个线程执行,不在一个线程中,这条规则不适用。

  • 监视器锁定规则:TestDemo类中,我们没有用synchronized关键字修饰过,没有同步块,不会发生lock和unlock,这条规则不适用。

  • volatile变量规则:TestDemo类的value变量没有被volatile关键字修饰,这条规则不适用。

  • 线程启动规则:没有使用Thread类控制,这条规则不适用。

  • 线程终止规则:没有使用Thread类控制,这条规则不适用。

  • 线程中断规则:没有使用Thread类控制,这条规则不适用。

  • 传递性:TestDemo类没有找到先行发生关系,这条规则不适用。

结论:TestDemo类是一个线程不安全的类。即使线程A在时间上先于线程B执行,也无法确定线程B返回的结果。时间先后顺序于先行发生原则之间没有关系,所以我们碰到并发安全问题的时候不要受到时间顺序的干扰,一切都按照先行发生原则为准。

那么怎么让TestDemo变成一个线程安全的类呢?只要满足上面的先行发生原则中的某一条,JMM就能保证变量在并发过程中的原子性可见性有序性
就这个例子中,至少有两种简单的解决方法。

  1. 把set()和get()方法定义为synchronized方法,这样就有了监视器锁定规则。

  2. 把value变量用volatile修饰,这样就有了 volatile变量规则。


结语

程序不会按照你看到的顺序执行,即使是单线程环境。在多线程环境下,所有的事情都会变得更复杂,甚至是不可预测。

除非你能保证你的代码是运行在单线程环境下的,否则,你在编程的时候必须要考虑你现在正在操作的这个对象,在多线程环境下是否是线程安全的,是否会按照你头脑中想象的那样去执行。

正确使用JAVA API处理线程安全问题是困难的,理解多线程底层原理则更加困难,但是,理解了底层原理后再使用API处理线程安全问题会变的非常简单。

发布了45 篇原创文章 · 获赞 2 · 访问量 7815
展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客

分享到微信朋友圈

×

扫一扫,手机浏览