从jmm、jvm,到对象头、锁(长篇大论)更新中

2023-12-26 13:30

本文主要是介绍从jmm、jvm,到对象头、锁(长篇大论)更新中,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

JMM(java memory model) java内存模型

Java 内存模型指定 Java 虚拟机(jvm)如何使用计算机的内存 (RAM)。Java 虚拟机是整个计算机的模型,因此该模型自然包含一个内存模型——也就是 Java 内存模型。

为什么要有内存模型

在介绍Java内存模型之前,先来看一下到底什么是计算机内存模型,然后再来看Java内存模型在计算机内存模型的基础上做了哪些事情。要说计算机的内存模型,就要说一下一段古老的历史,看一下为什么要有内存模型。

内存模型,英文名Memory Model,他是一个很老的老古董了。他是与计算机硬件有关的一个概念。那么我先给你介绍下他和硬件到底有啥关系。

CPU和缓存一致性

我们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。

刚开始,还相安无事的,但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。

为解决上述内存的读写速度慢发展CPU技术的矛盾,

所以,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

而随着CPU能力的不断提升,一层缓存就慢慢的无法满足要求了,就逐渐的衍生出多级缓存。

按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存(L1),二级缓存(L2),部分高端CPU还具有三级缓存(L3),每一级缓存中所储存的全部数据都是下一级缓存的一部分。

这三种缓存的技术难度和制造成本是相对递减的,所以其容量也是相对递增的。

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。

随着计算机能力不断提升,开始支持多线程。那么问题就来了。我们分别来分析下单线程、多线程在单核CPU、多核CPU中的影响。

单线程。cpu核心的缓存只被一个线程访问。缓存独占,不会出现访问冲突等问题。

单核CPU,多线程。进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。

多核CPU,多线程。每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

在CPU和主存之间增加缓存,在多线程场景下就可能存在缓存一致性问题,也就是说,在多核CPU中,每个核的自己的缓存中,关于同一个数据的缓存内容可能不一致。

处理器重排和编译器优化

上面提到在在CPU和主存之间增加缓存,在多线程场景下会存在缓存一致性问题。除了这种情况,还有一种硬件问题也比较重要。那就是为了使处理器内部的运算单元能够尽量的被充分利用,处理器可能会对输入代码进行乱序执行处理。这就是处理器重排

除了现在很多流行的处理器会对代码进行优化乱序处理,很多编程语言的编译器也会有类似的优化,比如Java虚拟机的即时编译器(JIT)也会做编译器优化

至于为什么重排,我们举个例子,假设你要运一货车苹果,现在你要把苹果装箱上车。你有两种极端的选择:装一箱子苹果,搬到货车边上,再推上去摆到车厢里......一箱一箱的依次进行;另一种方式是先全部装好箱,然后全部搬到货车边上,最后全部挪进去摆好位置。

那种效率更高?很明显是后者,因为前者你就需要不停地在装箱,搬运和上车摆放之间切换,这个切换过程不仅浪费时间,还耗费精力。但是后者一直做一个工作也很无聊,还会导致领导来检查时候车上一箱苹果也没装好,会觉得你在磨蹭摸鱼,所以比较合适的做法就是拿出来两三个箱子,把这些装好,一次多搬运几个过去。这样老板看到就会夸你很有工作效率。

再想想,如果给你多安排有两个人,一个负责装箱,一个负责搬运,一个负责装车,就更快了。

那么编译期重排序有什么好处?CPU计算的时候要访问值,如果常常利用到寄存器中已有的值就不用去内存读取了,比如说:

int a = 1;
int b = 1;
int a = a+1;
int b = b+1; 

就没有如下的效果好: 

int a = 1;
int a = a+1;
int b = 1;
int b = b+1;

因为后者的 a或b可能在寄存器中了。

处理器为什么要重排序?因为一个汇编指令也会涉及到很多步骤,每个步骤可能会用到不同的寄存器,CPU使用了流水线技术,也就是说,CPU有多个功能单元(如获取、解码、运算和结果),一条指令也分为多个单元,那么第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似,所以一般可以通过指令重排使得具有相似功能单元的指令接连执行来减少流水线中断的情况。

关于重排序分为以下三种:

  1. 编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术,将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用了缓存(cpu cache)和读/写缓冲区(store buffers),使得加载和存储操作看上去可能是乱序的。

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。

JMM 属于语言级的内存模型. 它确保在不同的编译器和处理器平台上, 通过禁止特定类型的编译器和处理器重排序, 对外提供一致的内存可见性保证.

在多线程编程中,就涉及到线程之间的通信。为了更好的实现程序的高并发、高性能、高可用,就不得不知道JMM。至于高可用可参考https://www.linuxprobe.com/high-availability.html

并发编程的问题

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。

可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

有序性即程序执行的顺序按照代码的先后顺序执行。

有没有发现,缓存一致性问题其实就是可见性问题编译器优化的重排序在不改变单线程程序语义的情况下重新安排语句的执行顺序,所以破坏了顺序性。而处理器的指令级重排序则会破坏原子性。

什么是内存模型

所以,为了保证并发编程中可以满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型。

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:限制处理器优化使用内存屏障

什么是Java内存模型

前面介绍过了计算机内存模型,这是解决多线程场景下并发问题的一个重要规范。那么具体的实现是如何的呢,不同的编程语言,在实现上可能有所不同。

我们知道,Java程序是需要运行在Java虚拟机上面的,Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

简要言之,jmm是jvm的一种规范,定义了jvm的内存模型。它屏蔽了各种硬件和操作系统的访问差异,不像c那样直接访问硬件内存,相对安全很多,它的主要目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。可以保证并发编程场景中的原子性、可见性和有序性。

提到Java内存模型,一般指的是JDK 5 开始使用的新的内存模型,主要由JSR-133: JavaTM Memory Model and Thread Specification 描述。

Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

而JMM就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。

推荐看一下《JAVA并发编程的艺术》

Java内存模型的实现

了解Java多线程的朋友都知道,在Java中提供了一系列和并发处理相关的关键字,比如volatilesynchronizedfinalconcurren包等。其实这些就是Java内存模型封装了底层的实现后提供给程序员使用的一些关键字。

在开发多线程的代码的时候,我们可以直接使用synchronized等关键字来控制并发,从来就不需要关心底层的编译器优化、缓存一致性等问题。所以,Java内存模型,除了定义了一套规范,还提供了一系列原语,封装了底层实现后,供开发者直接使用。

本文并不准备把所有的关键字逐一介绍其用法,因为关于各个关键字的用法,网上有很多资料。读者可以自行学习。本文还有一个重点要介绍的就是,我们前面提到,并发编程要解决原子性、有序性和一致性的问题,我们就再来看下,在Java中,分别使用什么方式来保证。

原子性

在Java中,为了保证原子性,提供了两个高级的字节码指令monitorentermonitorexit。在synchronized的实现原理文章中,介绍过,这两个字节码,在Java中对应的关键字就是synchronized

因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。

可见性

Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronizedfinal两个关键字也可以实现可见性。只不过实现方式不同,这里不再展开了。

有序性

在Java中,可以使用synchronizedvolatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。

好了,这里简单的介绍完了Java并发编程中解决原子性、可见性以及有序性可以使用的关键字。读者可能发现了,好像synchronized关键字是万能的,他可以同时满足以上三种特性,这其实也是很多人滥用synchronized的原因。

但是synchronized是比较影响性能的,虽然编译器提供了很多锁优化技术,但是也不建议过度使用。

jvm和jmm之间的关系

JVM是对于JMM约定的具体实现方法,将内存分为五个部分,方法区,堆,JVM栈,本地方法栈,程序计数器。前两者属于线程共有,后三者属于线程私有。方法区存储类、常量、JIT即时编译的方法代码,类加载信息的引用等等。堆存储对象。JVM栈主要由方法栈帧组成,栈帧包含方法内的局部变量,操作数栈、动态链接和出口地址。本地方法是JVM本身运行的方法和调用其他语言的区域。程序计数器记录线程执行的地址,方便线程切换。

jmm中的主内存、工作内存与jvm中的Java堆、栈、方法区等并不是同一个层次的内存划分,这两者基本上是没有关系的,如果两者一定要勉强对应起来,那从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(甚至是硬件系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

JVM内存模型

程序计数器(PC)

程序计数器是一块很小的内存空间,用于记录下一条要运行的指令。每个线程都需要一个程序计数器,各个线程之中的计数器相互独立,是线程中私有的内存空间

为什么需要程序计数器

我们知道对于一个处理器(如果是多核cpu那就是一核),在一个确定的时刻都只会执行一条线程中的指令,一条线程中有多个指令,为了线程切换可以恢复到正确执行位置,每个线程都需有独立的一个程序计数器,不同线程之间的程序计数器互不影响,独立存储。

注意:如果线程执行的是个java方法,那么计数器记录虚拟机字节码指令的地址。如果为native【底层方法】,那么计数器为空。这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域
 

java虚拟机栈

java虚拟机栈也是线程私有的内存空间,它和java线程同一时间创建,由java语言实现的,保存了局部变量、部分结果,并参与方法的调用和返回

本地方法栈

本地方法栈和java虚拟机栈的功能相似,java虚拟机栈用于管理Java函数的调用,而本地方法栈用于管理本地方法的调用,但不是由Java实现的,而是由C实现的

java堆

为所有创建的对象和数组分配内存空间,被JVM中所有的线程共享

方法区

也被称为永久区,与堆空间相似,被JVM中所有的线程共享。方法区主要保存的信息是类的元数据,方法区中最为重要的是类的类型信息、常量池、域信息、方法信息,其中运行时常量池就在方法区,对永久区的GC回收,一是GC对永久区常量池的回收;二是永久区对元数据的回收

在JVM内部使用的java内存模型(JMM)将线程堆栈和堆之间的内存分开

根据JMM模型,在JVM把内存分成了两部分:线程栈区堆区

JVM中运行的每个线程都拥有自己的线程栈(也称调用栈),线程栈包含了当前线程执行的方法调用相关信息。随着代码的不断执行,调用栈会不断变化。


线程堆栈(thread stack):

1.运行在java虚拟机上的每个线程都有自己的线程堆栈(thread stack)

2.线程堆栈还包含正在执行的每个方法的所有局部变量,一个线程只能访问它自己的线程堆栈。由线程创建的局部变量对于除创建它的线程之外的所有其他线程都是不可见的。

3.即使两个线程正在执行完全相同的代码,两个线程仍然会在每个线程堆栈中创建该代码的局部变量,一个线程可能会将一个有限变量的副本传递给另一个线程,但它不能共享原始局部变量本身

堆:

1.堆包含在Java应用程序中创建的所有对象,而不管是不是由线程创建的该对象。

2.堆中的对象可以被具有对象引用的所有线程访问。当一个线程访问一个对象时,它也可以访问该对象的成员变量。

3.如果两个线程同时调用同一个对象上的一个方法,它们都可以访问该对象的成员变量,但每个线程都有自己的局部变量副本

4.堆中的数据是共享的,线程不安全的

详细说明:

  • 所有原始类型(boolean,byte,short,char,int,long,float,double)的局部变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的局部变量,一个线程可以传递一个变量副本给另一个线程,但原始变量是不共享的。
  • 堆区包含了Java应用创建的所有对象信息(包括原始类型的封装类),不管对象是哪个线程创建的,不管对象是属于一个成员变量还是方法中的局部变量,它都会被存储在堆区。
  • 一个局部变量如果是原始类型,那么它会被完全存储到栈区。 一个局部变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。
  • 对于一个对象的成员方法,这些方法中包含局部变量,仍需要存储在栈区,即使它们所属的对象在堆区。
  • 对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。
  • Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

基于JMM的JVM模型,既然堆中的数据是共享的,那么在多线程环境中,就可能存在数据安全性问题。主要涉及到:可见性问题,竞争性问题等。

在此之前,回顾几个点

A、计算机常识:

  • cpu执行的操作是原子性的,是不可拆分的。

B、造成数据安全性问题的必要条件

  • 多线程环境
  • 多个线程操作共享数据
  • 操作共享数据的语句不是原子性的(多条)

共享对象的可见性

如果两个或多个线程共享一个对象,但没有正确使用volatile声明或Synchronized同步机制,一个线程更新了共享变量值后,对于其他线程来讲是不可见的。如线程A,线程B同时要进行modify,

public class Account {private float balance;public void modify (float difference) {float value=this.balance;this.balance=value+difference;}
}

首先,线程A和线程B在各自的thread stack中维护了一分局部变量的副本,线程A修改修改了线程A中Thread stack中的局部变量,但是还没有还没将修改的数据刷新到Main Memory中,而线程B获取的值依然是old value,就会出现问题

解决方案

  • 使用volatile关键字
  • 使用synchronized同步机制

tips:volatile与synchronized的区别:

  • volatile本质是在告诉jvm当前变量在寄存器中的值是不确定的,需要从主存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
  • volatile仅修饰变量;synchronized则可以修饰变量、方法、代码块
  • volatile仅保证可见性;synchronized则可以保证可见性和原子性
  • volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
  • volatile修饰的变量会禁止指令重排序,因而程序不会被编译器优化;synchronized修饰的变量没有禁止指令重排序,因而程序可以被编译器优化

关于对象头,先从Java的对象模型谈起

Java是一种面向对象的语言,而Java对象在JVM中的存储也是有一定的结构的。而这个关于Java对象自身的存储模型称之为Java对象模型。

HotSpot虚拟机中,设计了一个OOP-Klass Model。OOP(Ordinary Object Pointer)指的是普通对象指针,而Klass用来描述对象实例的具体类型。

每一个Java类,在被JVM加载的时候,JVM会给这个类创建一个instanceKlass,保存在方法区,用来在JVM层表示该Java类。当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据。

HotSpot虚拟机中,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。java对象头很重要,synchronize、GC、HashCode、biasedLock、ObjectMonitor都是在对象头上做文章。

(32位)

在JVM存储时,为了实现一些额外的功能,需要在对象中添加一些标记字段用于增强对象功能,这些标记字段组成了对象头。Mark Word:用来标记运行时信息。Class Pointer:用来指向生成该对象所在的类。如果是数组对象还得再加一项Array Length:告诉我们数组的长度。

这里以32位JVM为例:

普通对象

 数组对象

Mark Word(标记字段)

通过阅读open jdk官方文档中对对象头的解释可以知道,一个Java对象头中包含了2个word。第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等。第二个word是klass word,主要是指向对象的元数据 。

关于Word(字):指的是计算机内存中占据 一个单独的内存单元编号的一组二进制串。一般32位计算机上一个字为4个字节长度。1 Byte = 8Bits。

这部分数据的长度在32位和64位的虚拟机(暂不考虑开启压缩指针的场景)中分别为32个和64个Bits,官方称它为“Mark Word”。mark word的位长度为JVM的一个Word大小,也就是说32位JVM的Mark word为32Bits,64位JVM为64Bits。(64位虚拟机情况下,markWord、class pointer、array length一般都是8字节)

考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机 中对象未被锁定的状态下,Mark Word的32个Bits空间中的25Bits用于存储对象哈希码(HashCode),4Bits用于存储对象分代年龄,2Bits用于存储锁标志 位,1Bit固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下表所示。  

其中各部分的含义如下:
lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个mark word表示的含义不同。

biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。
age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。
identity_hashcode:25位的对象标识Hash码,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象被锁定时,该值会移动到管程Monitor中。
thread:持有偏向锁的线程ID。
epoch:偏向时间戳。
ptr_to_lock_record:指向栈中锁记录的指针。
ptr_to_heavyweight_monitor:指向管程Monitor的指针。 

64位下的标记字与32位的相似:

 

锁有不同的分类。分别是:无锁态、偏向锁、轻量级锁 (自旋锁,自适应自旋)、重量级锁。锁的相关信息必然是要被记录的而记录的载体正是对象的对象头中的markword。此外由于存储空间的有限,为了节省空间,提高空间利用率64位虚拟机下,我们将markword这8个字节根据不同的锁状态划分成了不同的结构。其中锁状态与markword的关系入下图所示。

关于锁的介绍,具体我会在后面,详细的说明。

class pointer

对象头另外一部分是类型指针,即是对象指向它的类的元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说查找对象的元数据信息并不一定要经过对象本身。另外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。 

这一部分用于存储对象的类型指针,该指针指向它的类元数据,JVM通过这个指针确定对象是哪个类的实例。该指针的位长度为JVM的一个字大小,即32位的JVM为32位,64位的JVM为64位。
如果应用的对象过多,使用64位的指针将浪费大量内存,统计而言,64位的JVM将会比32位的JVM多耗费50%的内存。为了节约内存可以使用选项+UseCompressedOops开启指针压缩,其中,oop即ordinary object pointer普通对象指针。开启该选项后,下列指针将压缩至32位:

  1. 每个Class的属性指针(即静态变量)
  2. 每个对象的属性指针(即对象变量)
  3. 普通对象数组的每个元素指针

当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。

array length

如果对象是一个数组,那么对象头还需要有额外的空间用于存储数组的长度,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位

附上参考资料:

CompressedOops - CompressedOops - OpenJDK Wiki

实例数据(Instance Data)

接下来实例数据部分是对象真正存储的有效信息,也既是我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的都需要记录下来。 这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。HotSpot虚拟机 默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果 CompactFields参数值为true(默认为true),那子类之中较窄的变量也可能会插入到父类变量的空隙之中。

对齐填充(Padding)

 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头正好是8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。
 

对象的访问定位

java程序需要通过引用(ref)数据来操作堆上面的对象,那么如何通过引用定位、访问到对象的具体位置。

对象的访问方式由虚拟机决定,java虚拟机提供两种主流的方式
1.句柄访问对象
2.直接指针访问对象。(Sun HotSpot使用这种方式)

参考Java对象访问定位

句柄访问

简单来说就是java堆划出一块内存作为句柄池,引用中存储对象的句柄地址,句柄中包含对象实例数据、类型数据的地址信息。

优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。

直接指针

与句柄访问不同的是,ref中直接存储的就是对象的实例数据,但是类型数据跟句柄访问方式一样。

优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】

内存溢出

两种内存溢出异常[注意内存溢出是error级别的]
1.StackOverFlowError:当请求的栈深度大于虚拟机所允许的最大深度
2.OutOfMemoryError:虚拟机在扩展栈时无法申请到足够的内存空间[一般都能设置扩大]

java -verbose:class -version 可以查看刚开始加载的类,可以发现这两个类并不是异常出现的时候才去加载,而是jvm启动的时候就已经加载。这么做的原因是在vm启动过程中我们把类加载起来,并创建几个没有堆栈的对象缓存起来,只需要设置下不同的提示信息即可,当需要抛出特定类型的OutOfMemoryError异常的时候,就直接拿出缓存里的这几个对象就可以了。

比如说OutOfMemoryError对象,jvm预留出4个对象【固定常量】,这就为什么最多出现4次有堆栈的OutOfMemoryError异常及大部分情况下都将看到没有堆栈的OutOfMemoryError对象的原因。

参考OutOfMemoryError解读


 

再说说JVM中的锁

      看完对象头,往下看虚拟机中对锁的一些实现,Java程序中对于多线程的同步操作并不是全部交由操作系统来完成的,JVM也会参与进来,例如遇到线程访问临界区资源但没能获得锁而等待时,JVM不会立刻让操作系统挂起线程,而是做一些例如自旋操作,让线程尽可能拿到锁,这样做的目的是尽可能提高程序运行速度,因为线程拿不到锁后进入阻塞态,等待下一次获取锁后进入就绪态再到运行态,这一系列动作需要耗费较大的性能和时间。

先来看看synchronized机制,synchronized机制有四种用法,一种是对代码块标记,第二种对静态方法进行标记,第三种对非静态方法进行标记,第四种对类进行标记。其中反编译之后可以看到,class文件中的指令通过monitorenter和monitorexit来标识代码片段是互斥的,如果是方法,方法名之前会有ACC_SYNCHRONIZED的标识。

至于底层实现,jvm通过一系列的队列来保证synchronized能够线程安全的试用。每一个对象都有一个monitor来控制是否是竞争性资源。现有的机制通过对对象头的标记来简化锁带来的负载,偏向锁,不存在竞争的线程获取资源,第二次进入不再进行同步操作。轻量锁,先尝试修改mark word的状态为轻量锁,修改成功就执行同步的代码,此时有竞争的线程自旋,自旋之后如果还不能获取到资源,锁变成重量锁。自旋锁,线程空转以免引起操作系统上下文切换。重量锁,通过synchronized机制来操作,只有在其他线程执行了monitorexit之后才能获获取资源。

vilatile 是能够保证可见性和有序性。应用场景有状态标志(开关)、双重检查锁定,在底层的实现主要依靠锁,但是vilatile并不能保证原子性,所以不是线程安全的机制。对单个volatile变量的读/写具有原子性,但是部分操作不具有,比如volatile++这样的操作

偏向锁

      首先来看偏向锁,它的做法是,如果一个线程A获取了锁对象,那么这个锁对象就进入了偏向状态,Mark Word中标记为biased_lock:1 | 01,同时经过CAS无锁交换比较(compare and swap)操作后记录线程id,线程执行完释放锁之后,如果想要再次获得该锁对象,则不需要再进行加锁,cas等同步操作。当然,如果有其他线程试图去获取该锁对象时,这个偏向状态就会失效。

轻量级锁

      轻量级锁在JVM内部实现用的是BasicObjectLock类,类里面有轻量锁对象BasicLock,线程如果获取偏向锁失败,就会转去申请轻量级锁,轻量级锁的就是先让线程进行CAS操作来同步,期间还会将原来对象的Mark Word进行备份,然后复制BasicLock的地址到Mark Word中,如果BasicLock地址复制成功,表示线程处于轻量级锁状态,Mark Word中标记lock为00。如果地址复制失败,先会去判断线程是否之前就已经获得了锁对象,如果是就直接进入同步块,如果没有,则表明有多个线程在获取同一对象,此时轻量级锁就会膨胀成重量级锁。

​​​​​关于对象头和锁之间的转换,网上大神总结

关于CAS操作

参考资料:

JVM内存结构 VS Java内存模型 VS Java对象模型-HollisChuang's Blog

java对象在内存中的结构(HotSpot虚拟机) - duanxz - 博客园

【Java对象解析】不得不了解的对象头_扬帆舟的博客-CSDN博客

Java对象头详解 - 追求极致 - 博客园

深入理解JVM-内存模型(jmm)和GC - 简书

JMM与JVM区别与联系(精炼总结) - it610.com

这篇关于从jmm、jvm,到对象头、锁(长篇大论)更新中的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/539584

相关文章

JVM 的类初始化机制

前言 当你在 Java 程序中new对象时,有没有考虑过 JVM 是如何把静态的字节码(byte code)转化为运行时对象的呢,这个问题看似简单,但清楚的同学相信也不会太多,这篇文章首先介绍 JVM 类初始化的机制,然后给出几个易出错的实例来分析,帮助大家更好理解这个知识点。 JVM 将字节码转化为运行时对象分为三个阶段,分别是:loading 、Linking、initialization

Spring Security 基于表达式的权限控制

前言 spring security 3.0已经可以使用spring el表达式来控制授权,允许在表达式中使用复杂的布尔逻辑来控制访问的权限。 常见的表达式 Spring Security可用表达式对象的基类是SecurityExpressionRoot。 表达式描述hasRole([role])用户拥有制定的角色时返回true (Spring security默认会带有ROLE_前缀),去

浅析Spring Security认证过程

类图 为了方便理解Spring Security认证流程,特意画了如下的类图,包含相关的核心认证类 概述 核心验证器 AuthenticationManager 该对象提供了认证方法的入口,接收一个Authentiaton对象作为参数; public interface AuthenticationManager {Authentication authenticate(Authenti

Spring Security--Architecture Overview

1 核心组件 这一节主要介绍一些在Spring Security中常见且核心的Java类,它们之间的依赖,构建起了整个框架。想要理解整个架构,最起码得对这些类眼熟。 1.1 SecurityContextHolder SecurityContextHolder用于存储安全上下文(security context)的信息。当前操作的用户是谁,该用户是否已经被认证,他拥有哪些角色权限…这些都被保

Spring Security基于数据库验证流程详解

Spring Security 校验流程图 相关解释说明(认真看哦) AbstractAuthenticationProcessingFilter 抽象类 /*** 调用 #requiresAuthentication(HttpServletRequest, HttpServletResponse) 决定是否需要进行验证操作。* 如果需要验证,则会调用 #attemptAuthentica

Spring Security 从入门到进阶系列教程

Spring Security 入门系列 《保护 Web 应用的安全》 《Spring-Security-入门(一):登录与退出》 《Spring-Security-入门(二):基于数据库验证》 《Spring-Security-入门(三):密码加密》 《Spring-Security-入门(四):自定义-Filter》 《Spring-Security-入门(五):在 Sprin

Java架构师知识体认识

源码分析 常用设计模式 Proxy代理模式Factory工厂模式Singleton单例模式Delegate委派模式Strategy策略模式Prototype原型模式Template模板模式 Spring5 beans 接口实例化代理Bean操作 Context Ioc容器设计原理及高级特性Aop设计原理Factorybean与Beanfactory Transaction 声明式事物

Java进阶13讲__第12讲_1/2

多线程、线程池 1.  线程概念 1.1  什么是线程 1.2  线程的好处 2.   创建线程的三种方式 注意事项 2.1  继承Thread类 2.1.1 认识  2.1.2  编码实现  package cn.hdc.oop10.Thread;import org.slf4j.Logger;import org.slf4j.LoggerFactory

poj3468(线段树成段更新模板题)

题意:包括两个操作:1、将[a.b]上的数字加上v;2、查询区间[a,b]上的和 下面的介绍是下解题思路: 首先介绍  lazy-tag思想:用一个变量记录每一个线段树节点的变化值,当这部分线段的一致性被破坏我们就将这个变化值传递给子区间,大大增加了线段树的效率。 比如现在需要对[a,b]区间值进行加c操作,那么就从根节点[1,n]开始调用update函数进行操作,如果刚好执行到一个子节点,

hdu1394(线段树点更新的应用)

题意:求一个序列经过一定的操作得到的序列的最小逆序数 这题会用到逆序数的一个性质,在0到n-1这些数字组成的乱序排列,将第一个数字A移到最后一位,得到的逆序数为res-a+(n-a-1) 知道上面的知识点后,可以用暴力来解 代码如下: #include<iostream>#include<algorithm>#include<cstring>#include<stack>#in