java内存模型

201 阅读12分钟

前言

让时光积累出真正的价值

在很长的一段时间里面,我看到和JVM相关的内容基本上会跳过,觉得这些内容在实际的编程中用处不大。在看了一些有关并发编程的内容以后才发现,这些和JVM相关的内容不了解的话是很难看得懂并发编程的。

面试的时候遇也会到一些问Java内存模型的问题,在面对这些问题的时候自己根本无从下笔。。

正好同事给我推荐了这篇文章,我觉得写得挺好的,将我自己了解到的内容记录在这里。

下面是我参考的文章

全面理解Java内存模型(JMM)及volatile关键字blog.csdn.net/javazejian/…

Java内存区域

从Java线程的角度来分类,可以将内存区域分成两大类:Java线程独占内存区域Java线程共享区域,如下图。其中Java线程独占区域又分为虚拟机栈区、本地方法栈和程序计数器区,线程独占区域又分为堆和方法区。

线程共享区域

作为所有线程共享的区域,有两种数据区域:

1 . 堆空间(Java Heap):JVM堆空间,在初学Java的时候经常听到过堆和栈这两个内存区域。一般来说使用new关键字创建的Java对象都会放在堆空间。对象一旦存储在这里,生命周期会受到JVM的管理。对象的销毁工作是通过垃圾回机制完成的。

2 . 方法区(Methods Area 或者 Non Heap 非堆)一般来说是存放类加载的数据,用来存放类加载器加载的类源信息、类当中的静态变量、常量等数据。(大致理解为类加载区,应该就是维持类的正常运行所需要的数据都会放在方法区)。方法区里面还维护了一个运行时常量池,这个运行时常量池主要保存了运行时产生的字面量和常量,其中字符串常量、数字常量等待常量都是放在这里的。

在Object对象的数据中,有一个数据指向类的源信息。这个指向的地方就是存放在方法区里面的元数据信息。可以通过Object.getClass()方法来获取。

方法区和堆空间在无法继续分配内存的时候会抛出OutOfMemoryError

非线程共享区域

简单的来说就是线程私有数据区域。在这里面分别有程序计数器、虚拟机栈、本地方法栈等数据区域

1 . 程序计数器(Program Conter Register) 简单的来说就是标记着当前的线程程序执行到哪个语句的行号(实际上是编译后的指令),支撑着线程中断和恢复的功能(包括分分支、循环、异常处理、线程恢复等)。

2 . 虚拟机栈 :(Java Virtual Machine Stack)线程的私有区域 ,与线程一一对应(总数与线程数关联)。线程每运行一个方法时都会创建一个栈帧,**栈帧会保存以下内容局部变量表、方法中的出现的变量、操作数栈:、当前操作到的地方动态连接方法 、调用其他的方法返回值返回地址返回地址类型等。当方法调用新的一个方法时,会创建一个新的栈帧压入虚拟机栈当中。

3.本地方法栈(Native Method Stacks)这个区域与线程调用的原生方法(C++ 实现的方法)有关,用户无需关心这个区域。

Java内存模型(JMM)

JMM(Java memory model)本身是定义了Java当中变量的访问方式的一种规范。Java中运行程序的基本单位是线程,每一个线程启动时jvm都会为这个线程分配一个工作空间(虚拟机栈)用来保存线程的私有数据。线程运行有一个特性——线程操作的数据必须存放在工作内存当中。这样就需要一个机制把数据从工作主内存拷贝到工作内存当中。

  1. Java内存模型是Java的一个规范,它规定的程序中各个数据的访问方式。Java内存分为主内存和工作内存。

  2. 其中主内存是所有线程都可以访问的区域,它保存了线程的所有数据(所有线程的数据都存放在主内存)。

  3. 工作内存顾名思义就是线程工作的时候使用的内存,工作内存的数据从主内存拷贝,拷贝到工作内存中的副本数据修改了以后会将修改后的数据重新写到主内存。

主内存
  1. Java实例化的对象、方法中的本地变量都保存在Java的主内存中。
  2. 因为所有线程都可以访问,且工作内存都是都是从主内存中拷贝,因为不同的线程有不同的数据副本所以有可能会产生线程安全问题。
工作内存
  1. 工作内存主要存储存储了当前线程用到的变量数据(主内存的副本),工作内存与线程一一对应,每个线程只能访问自己的工作内存。
  2. 工作线程保存了本地变量、本地native方法、行号指示器、字节码信息。
  3. 因为工作内存只能被自己的线程访问,工作内存不存在线程安全问题。
主内存和工作内存的交互

有线程A和线程B两个线程,它们需要访问主内存当中的数据ObjectA。按照JMM的规范,JVM会将ObjectA分别拷贝到线程A、B的工作内存当中。在它们的各自工作内存中,它们分别将ObjectA存放到对应的栈帧当中。如下图:

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

硬件上的内存架构

在硬件的层面上有CPU、CPU寄存器、CPU缓存、主内存。

  1. CPU:计算单元,多核架构下的一个计算单元,操作系统将线程映射到一个计算单元上并行执行。
  2. CPU寄存器:CPU直接操作的数据区域,当cup寄存器中没有CPU需要的数据时,操作系统会从依次从CPU缓存、主内存、硬盘当中查找数据,找到需要的数据以后将数据拷贝到CPU寄存器中。它是数据读写最快的存储结构。
  3. CPU缓存:主内存与CPU寄存器的信息通道,一般来说CPU缓存分分为多级(一级缓存、二级缓存、三级缓存)。它的数据读写速度比CPU寄存器慢,比内存要快。
  4. 主内存:保存全部程序运行所需的数据。

线程与硬件处理器

了解Java线程的实现机制,这样才能更好的理解内存模型与硬件的关系。

在window和Linux上,Java线程都是靠一对一模型实现。

所谓一对一线程模型,就是语言级别层面的程序区间接调用操作系统提供的API来实现的多线程。也就是说,当我们调用Java线程的时候,JVM是通过调用内核线程来完成任务的。

内核线程(Kernel Level Thread KLT)

这里就要介绍一下内核线程了,在这里内核线程可以看作是内核的一个分身。内核线程是操作系统内核支持的线程。这些内核线程是如何正常运作的呢? 原来在操作系统中有一个内核调度器(executor),负责切换、调度和将内核线程映射到内核上区运行。通过内核调度器,线程能正常的运行。

轻量级进程(Light Weight Process LWP)

在Java中,我们创建的线程不会直接去调用内核线程, 因为它太重量级了。取而代之的是一种轻量级进程。它就是我们通俗意义上的线程。轻量级进程和内核线程是一对一的个关系,通过轻量级进程调用内核线程,我们可以把用户的线程映射到对应的CPU上执行。

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

java内存模型的元素与硬件架构的元素不存在对等关系

  1. 硬件架构只有寄存器,多级缓存,主内存的分级,而JMM 有工作内存和主内存,两者具有交叉的地方,但是并不对等
  2. 实际上JMM的工作内存也是保存再主内存当中
  3. 硬件架构是物理上的概念,而JMM是逻辑上的概念

JMM存在的必要性

一致性问题

因为工作内存与主内存同步而产生数据不一致性问题:

因为JMM的机制是将数据先从主内存拷贝到工作内存,若有多个线程同时对同一个数据读写时容易出现数据不一致的问题

由图可见,因为线程B对线程A的修改变量A这个事件不可见,在做自增操作时直接使用原来的值来进行自增。最终导出线程安全问题。

JMM的规则

指令重排

为了提升计算机的运行速度,编译器和处理器有可能会对程序进行指令重排,指令重排主要有:

  1. 编译期指令重排:编译器在不影响程序语意的情况下,在程序编译期进行指令重排.

一个线程有 a=1 ;b=2 ;两个语句,因为他们并不存在依赖性,有可能出在编译时出现b=2;a=1;的顺序

  1. 多指令并行重排:因为处理器可以多指令并行执行(重叠执行),处理器可能对程序指令进行重排效果
  2. 内存优化重排:因为有些在放在后面执行的指令在寄存器,而前面的指令还在主内存当中,处理器可能会先执行后面的指令以提升运行速度

例如有语句a=1;b=2量行代码,因为在寄存器中有b的值,没有a的值,有可能在运行时先执行b=2再执行a=1

可见性问题

当一个共享的变量被一个线程修改时其他线程能否知道这个修改,这就是可见性的问题。在JMM模型下,工作内存使用的都是主内存的数据副本,且工作内存间相互不可见,这样会带来很大的可见性问题。

有序性

在单线程下,所有操作都是有序的。但是在多线程下,因为指令重排的与主从同步的问题下,多线程并不能保存有序性。(总结为两个视角,自己看自己时有序的,但是看其他人未必时有序的) 原子性

原子性指的是操作一旦开始不能被影响,不能被中断。比如我要对某个值进行操作原子性应该要求在在操作是它不能被其他线程影响,被中断。

对于32位系统来说,Double,Long这两种基本数据类型的读写不是原子性的原因:他们是64位的数据类型,32系统的原子操作是32位,所以读写操作要被分成两次来操作(前32位与后32位)

JMM的解决方案

在操作相同中,基本数据类型的读写是保证原子性,除此之外JVM还有很对解决方法保证线程安全

锁方案

可以对重要的代码快进行加锁操作来保证线程安全,锁主要有两种一种是以ReentrantLock为代表的显式加锁,Java中有人提供了一个关键字synchronized对代码加锁。另外一方面Java提供了原子操作类的包。

Happens-Before

为了保证原子性,有序性,可见性这些并发编程的必要条件都要显式使用Java通过的机制的化并发编程的门槛就太高了。所以JMM有一套基本原则来保证以上三项基本的特性。它是判断线程安全,是否存在竞争的依据。

  1. 程序顺序原则:对于同一个线程,它必然顺序执行的(语意具有串行性)。
  2. 锁原则:加了锁的对象只能等到对象解锁了以后再加锁(解锁操作必定发送再加锁之前)。
  3. volatile原则:每次访问volatile变量时都要先和主内存同步,每次操作volatile变量完都要强制写回主内存。(任何线程都能看到volatile变量的最新值)
  4. 线程启动原则:启动者修改主内存的数据,再启动者执行另外一个线程B的start方法以后,B一定能看到启动者startB前修改的数据。
  5. 线程关闭原则 :(1)Thread.join()的作用:等待另外一个线程结束。(2)原则:A线程调用B线程join方法,B线程结束前的所有修改都对等到B线程结束的A可见。
  6. 线程中断原则(1)Thread.interrupt()的作用:判断线程是否中断。(2)原则:线程A判断线程B是否中断,如果中断,B中断前的操作对A都可见。
  7. 对象终结原则(1)Object.finalize()方法:对象在被Gc回收前调用的方法。(2)原则:对象的构造方法优先与对象的折构方法。
  8. 传递性原则:A优先于B,B优先于C,那么A优先于C。