手记

java线程安全之内存模型

一、java内存模型

1、简介

各个区域的解释和功能

方法区(Method Area):方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。

JVM堆(Java Heap):Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。

程序计数器(Program Counter Register):属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码信号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

虚拟机栈(Java Virtual Machine Stacks):属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程。 

本地方法栈(Native Method Stacks):本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域

2、概念

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

Java内存模型中规定所有变量都存储在主内存;

JVM调度的实体是线程,JVM为各个线程创建了私有内存空间(工作内存);

线程操作要在各自的私有内存空间(工作内存)中,不能操作共享的主内存中的变量,工作内存中存储着主内存中的变量副本拷贝;变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存;(重点)

JMM是围绕原子性,有序性、可见性展开的。

3、主内存,工作内存理解

主内存:主要存储的是Java实例对象,包括成员变量和局部变量,还有共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。

工作内存:主要存储当前方法的所有局部变量(工作内存中存储着主内存中的变量副本拷贝);每个线程只能访问自己的工作内存,即线程中的局部变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的局部变量,当然也包括了字节码行号指示器、相关Native方法的信息;注意由于工作内存中数据是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。由于同时不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。下面是传值的模型图:

4、主内存与工作内存的数据存储类型以及操作方式

存储数据类型

根据虚拟机规范,对于一个实例对象中的成员方法而言:

1.局部变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存(JVM栈的帧栈)中;局部变量是引用类型,变量的引用会存储在工作内存(JVM栈的帧栈)中,而对象实例将存储在主内存(堆)中; 

2.成员变量不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,变量引用和实例都会被存储到主内存(堆区); 

3.全局变量(也就是static修饰的变量,也可以称为类变量)存储在主内存中(方法区中);

操作

主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,模型图如下: 

5、内存模型和内存区域划分的区别

JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,而后者是实际的区域划分;

JMM与Java内存区域唯一相似点,都存在共享数据区域和私有数据区域。在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区;而工作内存(线程私有数据区域),从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。

某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义

二.硬件内存架构与Java内存模型

1.CPU从内存中取数据,然后进行处理,但是内存的处理速度远远低于CPU,于是在寄存器和内存中间加了一个CPU缓存,虽小但是速度比内存快。 

2.寄存器不一定每次都能从缓存中取到数据,取不到就去内存中直接取。寄存器从缓存中取到数据的概率叫做命中率,影响着CPU的执行性能。 

3.CPU中处理完数据更新到内存中过程是一个相反的过程。

2、Java线程与硬件处理器

先来看几个概念,帮助理解Java线程与硬件处理器的关系。

1.进程和线程的概念

进程是资源管理的最小单位,线程是程序执行的最小单位。

2.Linux 线程模型

线程由来如下: 

Linux最开始没有线程的概念;那么带来问题运行效率低;多进程中上下文切换效率低,多线程中高,于是有了LinuxThreads;线程机制LinuxThreads所采用的就是线程-进程”一对一”模型;LinuxThreads 最初的设计相信相关进程之间的上下文切换速度很快,因此每个内核线程足以处理很多相关的用户级线程。这就导致了一对一 线程模型(即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程);但仍有明显缺点,后来有了改进版本也就是NPTL。 

NPTL,或称为 Native POSIX Thread Library,是 Linux 线程的一个新实现,它克服了 LinuxThreads 的缺点,同时也符合 POSIX 的需求。与 LinuxThreads 相比,它在性能和稳定性方面都提供了重大的改进。与 LinuxThreads 一样,NPTL 也实现了一对一的模型(即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程)。

3.三个主要的概念——内核线程、轻量级进程、用户线程

内核线程: 

内核线程就是内核的分身,一个分身可以处理一件特定事情。 

内核线程只能由内核管理并像普通进程一样被调度。 

轻量级进程(Light Weight Process) 

轻量级线程(LWP)是一种由内核支持的用户线程。 

轻量级进程(LWP)是建立在内核之上并由内核支持的用户线程,它是内核线程的高度抽象。每一个轻量级进程都与一个特定的内核线程关联。 

注意轻量级进程:首先,大多数LWP的操作,如建立、析构以及同步,都需要进行系统调用。系统调用的代价相对较高:需要在user mode和kernel mode中切换。 

其次,每个LWP都需要有一个内核线程支持,因此LWP要消耗内核资源(内核线程的栈空间)。因此一个系统不能支持大量的LWP。 

用户线程: 

这里的用户线程指的是完全建立在用户空间的线程库,用户线程的建立,同步,销毁,调度完全在用户空间完成,不需要内核的帮助。因此这种线程的操作是极其快速的且低消耗的。

4、JVM中线程的实现原理

1.我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务; 

2.内核线程(Kernel-Level Thread,KLT):它是由操作系统内核(Kernel)支持的线程,操作系统内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。 

3.调用一个线程,调用的是用户空间的线程库,线程库中每个线程对应着一个轻量级进程,而一个轻量级进程对应一个内核线程,所有的内核线程经内核线程调度器调度交由CPU完成相应操作。

流程示意图

5、Java内存模型与硬件内存架构的关系

1.Java内存模型(JMM)只是一个抽象的概念,是一种规则,并不是真正存在的结构;硬件内存结构是存在的物理结构。

通过对Java内存模型,硬件内存架构、以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

三.理解线程安全问题的原因

1、线程安全问题

举例:Thread1和Thread2都想操作主内存中的共享变量i,i原来是2。现在Thread1操作完之后想将工作内存中的值3更新到主内存中,同时Thread2想读取主内存中的i。那么Thread2读到的i是2还是3呢。就带来了不确定性,如果Thread1更新主内存的动作先于Thread2读取主内存中数据完成,那么Thread2中i就是3;如果相反,那么读到的就是2。这个不确定性就是线程安全问题。

2、Java内存模型的承诺

我们来看一下原子性,可见性,有序性

1原子性

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。 

如果不能够保证原子性操作,在多线程环境中就会带来线程安全问题。

2可见性

在理解可见性之前先看一下指令重排 

1.编译器优化的重排 

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2.指令并行的重排 

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序

3.内存系统的重排 

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

上面第一种属于编译器重排,后两种属于处理器重排。在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,

了解更多指令重排可以看这里 

最后看一下可见性概念: 

可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。 

单线程中肯定不存在这个问题,因为程序按照串行顺序进行。 

多线程情况下,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题,从而带来线程安全问题。

4.有序性

单线程中:认为代码按照顺序依次执行的,是有序的执行。 

多线程中:指令分配给不同的线程执行,并且加上上面指令重排现象,其实就带来了无序性。也是造成线程安全问题的原因。




0人推荐
随时随地看视频
慕课网APP