Java中Java对象结构和各种类型锁的理解

1 Java对象结构

1.1 栈对象概述

1.1.1 问题引入

JAVA中的引用的内存分配大小是多少?
如,我声明了一个对象,它在栈中就先有了个空间,(用来放地址引用的),这个空间的大小是多少?

java规范中并没有强行定义虚拟机中任何一种类型在虚拟机中所占用内存的具体大小,但是规范规定了每种类型的取值范围。从这种角度上看,每种类型会有一个最小位宽,或者内存占用大小。
而且java虚拟机规定中,在方法区中所占用的内存大小与在栈帧所占用的内存大小不同,因为 在方法区中占用内存以字节为最小单位,但是在战帧中以字为最小单位 。如byte类型在方法区中它占用8位,为一个字节,但是在栈帧中以一个字,即32位来处理,其实就是当作一个int类型来处理。

引用类型其位宽int型一样,在方法区中它占用32位,4个字节,在栈帧中占用一个字。虚拟机实现者可以扩大这种内存占用量,因为虚拟机规定只要满足取值范围即可,并没有规定非要32位一个字才行

此处作为一个参考看看即可:

java的引用相当于c++的指针,C++的指针是占四个字节,java引用应该也是固定的大小。sun hotspot jvmibm jvm中都把引用实现为一个指针,因此在64位平台上,占8个字节,在32位平台上占4个字节。别的jvm不清楚。

  1. 引用本身的大小和操作系统的位数有关,占8个字节,在32位平台上占4个字节,这个应该是很自然的事情,因为32-bit的操作系统,在4G(2^32)的内存空间内找到某个地址,这个地址是用4bytes(32 bits)来表示的。
  2. 引用本身是保留在栈中的
  3. 引用所指的对象,是存放在堆中的

1.1.2 基本类型存储位置

Java中基本类型都是存在虚拟机栈中,而栈里又有局部变量表,局部变量表的最基本存储单元为slot变量槽

关于slot的理解

局部变量表中,最基本的存储单元为slot(变量槽)
参数值的存放总在局部变量数组的index 0 开始,到数组长度-1的索引结束。
局部变量表中存放编译器可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
在局部变量表中32位以内的类型只占用一个slot(包括上面提到的returnAddress类型),64位的类型占用两个.
byte short char在存储前被转化为int , boolean在存储前也被转化为int , 0 表示false,非0表示true,long和double则占据两个 Slot

boolean占多少个字节
由上可知一个slot槽占4个字节,也就是Java中占位最小单元为4个字节,所以boolean占4个字节

1.1.3 字长存储是否造成空间浪费

在Java中,栈的存储是按照字长4字节来存储的,即使存储的数据小于4字节,也会按照4字节的大小进行分配。这种设计确实可能会导致空间的浪费,但这是Java虚拟机为了简化内存管理和提高访问速度所做出的设计决策。
具体来说,Java虚拟机(JVM)在执行Java程序时,会为每个线程分配一个栈来存储局部变量、操作数栈、动态链接等信息。这些信息被组织成为栈帧,每当一个方法被调用时,就会在栈上创建一个新的栈帧。栈帧中的局部变量表和操作数栈都是按照特定的大小进行对齐的,通常这个大小就是4字节或者8字节,这取决于操作系统和JVM的实现。
Java对象要求是8个字节的整数倍,这是因为JVM的对象内存布局遵循一定的对齐规则,即对象对齐(Object Alignment)。这种对齐有助于提高内存访问的速度,因为某些硬件平台在访问对齐的内存时会更加高效。对于数组类型,JVM会在数组对象的对象头中包含一个4字节大小的字段来记录数组的长度。

  • 基本数据类型:基本数据类型的大小在不同的平台上可能会有所不同,但 Java 规范定义了它们的最小大小:byte, boolean: 1 字节,short, char: 2 字节, int, float: 4 字节,long, double: 8 字节
  • 对齐:为了提高访问效率,Java 虚拟机可能会对局部变量进行对齐。例如,即使一个 byte 类型只需要 1 字节,但它可能会被对齐到 4 字节。
  • 对齐造成的空间浪费:由于对齐的原因,有时候可能会出现空间浪费。
    例如,如果在栈上有一个 byte 和一个 int,那么 byte 可能会被对齐到 4 字节,这样可能会浪费 3 字节的空间。
  • 过度优化:虽然对齐可能会导致一些空间浪费,但这种浪费通常是微不足道的,并且可以带来更高的性能。
  • 内存使用:与堆内存相比,栈内存通常更加有限。在大多数情况下,栈内存的空间不足可能不是主要的考虑因素。更重要的是如何有效地使用内存,而不是小的空间浪费

1.2 堆对象结构概述

HotSpot虚拟机中,对象在堆内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。下图是普通对象实例与数组对象实例的数据结构
在这里插入图片描述

1.2.1 堆对象头

HotSpot虚拟机的对象头包括两部分信息:

1.2.1.1 markword

第一部分markword,用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位64位的虚拟机(未开启压缩指针)中分别为32bit64bit,官方称它为MarkWord

markword数据的长度在32位64位的虚拟机(未开启压缩指针)中分别为32bit64bit,它的最后2bit是锁状态标志位,用来标记当前对象的状态,对象的所处的状态,决定了markword存储的内容,如下表所示:

状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 执行重量级锁定的指针
GC标记 11 空(不需要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄

32位虚拟机在不同状态下markword结构如下图所示:
在这里插入图片描述
64位虚拟机下,Mark Word64bit大小的,其存储结构如下:
在这里插入图片描述

1.2.1.2 klass

对象头的另外一部分是klass类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.

1.2.1.3 数组长度(只有数组对象有)

如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.

1.2.2 实例数据

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。无论是从父类继承下来的,还是在子类中定义的,都需要记录起来

1.2.3 对齐填充

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

1.3 对象大小计算

1.3.1 对象大小计算要点

  1. 32位系统下,存放Class指针的空间大小是4字节MarkWord4字节,对象头为8字节
  2. 64位系统下,存放Class指针的空间大小是8字节MarkWord8字节,对象头为16字节
  3. 64位开启指针压缩的情况下,存放Class指针的空间大小是4字节,MarkWord8字节,对象头为12字节。 数组长度4字节+数组对象头8字节(对象引用4字节(未开启指针压缩的64位为8字节)+数组markword为4字节(64位未开启指针压缩的为8字节))+对齐4=16字节。
  4. 静态属性不算在对象大小内

1.3.2 Object obj=new Object()占用字节

这是网上很多人都会提到的一个问题,那么结合上面的,我们来分析下,以64位操作系统为例,new Object()占用大小分为两种情况:

  • 未开启指针压缩
    占用大小为:8(Mark Word)+8(Class Pointer)=16字节
  • 开启了指针压缩(默认是开启的)
    开启指针压缩后,Class Pointer会被压缩为4字节,最终大小为:8(Mark Word)+4(Class Pointer)+4(对齐填充)=16字节

我们来验证一下。首先引入一个pom依赖

<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.10</version>
</dependency>

java示例

import org.openjdk.jol.info.ClassLayout;

public class HeapMemory {
    public static void main(String[] args) {
        Object obj = new Object();
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

在这里插入图片描述
最后的结果是16字节,没有问题,这是因为默认开启了指针压缩,那我们现在把指针压缩关闭之后再去试试

-XX:+UseCompressedOops 开启指针压缩
-XX:-UseCompressedOops 关闭指针压缩

在这里插入图片描述

再次运行,得到如下结果:
在这里插入图片描述
可以看到,这时候已经没有了对齐填充部分了,但是占用大小还是16位

新建一个类,内部只有一个byte属性

public class MyItem {
    byte i = 0;
}

然后分别在开启指针压缩和关闭指针压缩的场景下分别输出这个类的大小
开启指针压缩,占用16字节
在这里插入图片描述
关闭指针压缩,占用24字节
在这里插入图片描述
这个时候就能看出来开启了指针压缩的优势了,如果不断创建大量对象,指针压缩对性能还是有一定优化的

1.4 HotSpot对象模型

HotSpot中采用OOP-Klass模型,它是描述Java对象实例的模型,它分为两部分:

  • 类被加载到内存时,就被封装成了klassklass包含类的元数据信息,像类的方法、常量池这些信息都是存在klass里的,你可以认为它是java里面的java.lang.Class对象,记录了类的全部信息;
  • OOP(Ordinary Object Pointer)指的是普通对象指针,它包含MarkWord元数据指针MarkWord用来存储当前指针指向的对象运行时的一些状态数据;元数据指针则指向klass,用来告诉你当前指针指向的对象是什么类型,也就是使用哪个类来创建出来的;

那么为何要设计这样一个一分为二的对象模型呢?这是因为HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klassoop,其中oop中不含有任何虚函数,而klass就含有虚函数表,可以进行method dispatch
HotSpot中,OOP-Klass实现的代码都在/hotspot/src/share/vm/oops/路径下,oop的实现为instanceOoparrayOop,他们来描述对象头,其中arrayOop对象用于描述数组类型。

转载于:https://blog.csdn.net/zqz_zqz/article/details/70246212
https://blog.csdn.net/lihuifeng/article/details/51681146

2 Java中锁类型

2.1 锁基础理解

2.1.1 锁类型

锁从宏观上分类,分为悲观锁与乐观锁。

2.1.1.1 乐观锁

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取 在写时先读出当前版本号,然后加锁操作 (比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。

java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。

2.1.1.2 悲观锁

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock

2.1.2 java线程阻塞的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

2.1.3 markword

在介绍java锁之前,需要知道学习markword,如上面所提到的,markwordjava对象数据结构中的一部分,对象的markwordjava各种类型的锁密切相关

2.2 Java中锁

Java SE1.6为了减少获得锁和释放锁所带来的性能消耗,引入了偏向锁轻量级锁,所以在Java SE1.6里锁一共有四种状态,无锁状态,偏向锁状态,轻量级锁状态和重量级锁状态,它会随着竞争情况逐渐升级。锁可以升级但不能降级 ,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率

2.2.1 偏向锁

2.2.1.1 概念

Java偏向锁(Biased Locking)是Java6引入的一项多线程优化。
偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中,同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步的,这种情况下,就会给线程加一个偏向锁。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
详细点说:当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

它通过消除资源无竞争情况下的同步原理,进一步提高了程序的运行性能

2.2.1.2 偏向锁的实现

偏向锁获取过程:

  1. 访问Mark Word中偏向锁的标识是否设置成1,锁标志位是否为01,确认为可偏向状态。
  2. 如果为可偏向状态,则测试线程ID是否指向当前线程,如果是,进入步骤5,否则进入步骤3。
  3. 如果线程ID并未指向当前线程,则通过CAS操作竞争锁。如果竞争成功,则将Mark Word线程ID设置为当前线程ID,然后执行5;如果竞争失败,执行4。
  4. 如果CAS获取偏向锁失败,则表示有竞争。当到达全局安全点(safepoint)时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码。(撤销偏向锁的时候会导致stop the word:当程序运行到这些安全点的时候就会暂停所有当前运行的线程(Stop The World 即STW))
    注意:此步中到达安全点safepoint会导致stop the word,时间很短。
  5. 执行同步代码。

偏向锁的释放:

  • 偏向锁的撤销在上述第四步骤中有提到。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复到未锁定(标志位为01)或轻量级锁(标志位为00)的状态。

2.2.1.3 偏向锁的适用场景

始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致stop the word操作;
在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向锁的时候会导致进入安全点,安全点会导致stw(Stop The World ),导致性能下降,这种情况下应当禁用;

2.2.1.4 查看停顿–安全点停顿日志

要查看安全点停顿,可以打开安全点日志,通过设置JVM参数
-XX:+PrintGCApplicationStoppedTime 会打出系统停止的时间
添加-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 这两个参数会打印出详细信息,可以查看到使用偏向锁导致的停顿,时间非常短暂,但是争用严重的情况下,停顿次数也会非常多;

注意:安全点日志不能一直打开:

  1. 安全点日志默认输出到stdout,一是stdout日志的整洁性,二是stdout所重定向的文件如果不在/dev/shm,可能被锁。
  2. 对于一些很短的停顿,比如取消偏向锁,打印的消耗比停顿本身还大。
  3. 安全点日志是在安全点内打印的,本身 加大安全点的停顿时间

所以安全日志应该只在问题排查时打开。
如果在生产系统上要打开,再再增加下面四个参数:

-XX:+UnlockDiagnosticVMOptions -XX: -DisplayVMOutput -XX:+LogVMOutput -XX:LogFile=/dev/shm/vm.log

打开Diagnostic(只是开放了更多的flag可选,不会主动激活某个flag),关掉输出VM日志到stdout,输出到独立文件/dev/shm目录(内存文件系统)
在这里插入图片描述
此日志分三部分:

  1. 第一部分是时间戳,VM Operation的类型

  2. 第二部分是线程概况,被中括号括起来
    total: 安全点里的总线程数
    initially_running: 安全点开始时正在运行状态的线程数
    wait_to_block: 在VM Operation开始前需要等待其暂停的线程数

  3. 第三部分是到达安全点时的各个阶段以及执行操作所花的时间,其中最重要的是vmop
    spin: 等待线程响应safepoint号召的时间;
    block: 暂停所有线程所用的时间;
    sync: 等于 spin+block,这是从开始到进入安全点所耗的时间,可用于判断进入安全点耗时;
    cleanup: 清理所用时间;
    vmop: 真正执行VM Operation的时间。

可见,那些很多但又很短的安全点,全都是RevokeBias, 高并发的应用会禁用掉偏向锁。

2.2.1.5 jvm开启/关闭偏向锁

偏向锁在Java 6Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态
开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
关闭偏向锁:-XX:-UseBiasedLocking

2.2.1.6 JDK 15 要废弃偏向锁

JDK 15 决定废弃偏向锁的主要原因是:
在过去,Java 应用通常使用的都是 HashTable、Vector 等比较老的集合库,这类集合库大量使用了 synchronized 来保证线程安全。
所以偏向锁技术作为synchronized的一种优化手段,可以减少无锁竞争情况下的开销,通过假定一个锁一直由同一线程拥有,从而避免执行比较和交换的原子操作。
然而,随着Java应用程序的发展和优化,过去能够从偏向锁中获得的性能提升在当今的应用中不再明显。许多现代应用程序使用了不需要同步的集合类或更高性能的并发数据结构(如ConcurrentHashMap、CopyOnWriteArrayList等),而不再频繁地执行无争用的同步(synchronized)操作。
还有就是官方在文档中提到的,偏向锁的引入导致代码很复杂,给HotSpot虚拟机中锁相关部分与其他组件之间的交互也带来了复杂性。这种复杂性使得理解代码的各个部分变得困难,并且阻碍了在同步子系统内进行重大设计更改。因此,废弃偏向锁有助于减少复杂性,使代码更容易维护和改进。

总之,废弃偏向锁是为了减少复杂性、提高代码可维护性,并鼓励开发人员采用更现代的并发编程技术,以适应当今Java应用程序的性能需求。

2.2.2 轻量锁

2.2.2.1 概念

轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁;
轻量级锁的加锁过程:

  1. 在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为01状态,是否为偏向锁为0),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这时候线程堆栈与对象头的状态如图所示
    在这里插入图片描述

  2. 拷贝对象头中的Mark Word复制到锁记录中;

  3. 拷贝成功后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,并将Lock record里的owner指针指向object mark word。如果更新成功,则执行步骤4,否则执行步骤5。

  4. 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位设置为00,即表示此对象处于轻量级锁定状态,这时候线程堆栈与对象头的状态如图所示
    在这里插入图片描述
      

  5. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为10Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。 而当前线程便尝试使用自旋来获取锁,自旋就是为了不让线程阻塞,而采用循环去获取锁的过程

2.2.2.2 轻量级锁的释放

释放锁线程视角 :由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。

因为重量级锁被修改了,所有display mark word和原来的markword不一样了。
那么怎么补救,就是进入mutex前,compare一下objmarkword状态。确认该markword是否被其他线程持有

此时如果线程已经释放了markword,那么通过CAS后就可以直接进入线程,无需进入mutex,就这个作用。

尝试获取锁线程视角 :如果线程尝试获取锁的时候,轻量锁正被其他线程占有,那么它就会修改markword,修改重量级锁,表示该进入重量锁了。
还有一个注意点:等待轻量锁的线程不会阻塞,它会一直自旋等待锁,并如上所说修改markword
这就是自旋锁,尝试获取锁的线程,在没有获得锁的时候,不被挂起,而转而去执行一个空循环,即自旋。在若干个自旋后,如果还没有获得锁,则才被挂起,获得锁,则执行代码

2.2.3 自旋锁

2.2.3.1 概念

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
但是线程自旋是需要消耗cpu的,说白了就是让cpu在做无用功,如果一直获取不到锁,那线程也不能一直占用cpu自旋做无用功,所以需要设定一个自旋等待的最大时间。

如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态

2.2.3.2 自旋锁的优缺点

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换

但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,同时有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cpu的线程又不能获取到cpu,造成cpu的浪费。所以这种情况下我们要关闭自旋锁;

2.2.3.3 自旋锁时间阈值

自旋锁的目的是为了占着CPU的资源不释放,等到获取到锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用CPU资源,进而会影响整体系统的性能。因此自旋的周期选的额外重要
JVM对于自旋周期的选择,jdk1.5这个限度是一定得写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是 由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化

  1. 如果平均负载小于CPUs则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化则延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

2.2.3.4 自旋锁的开启

JDK1.6-XX:+UseSpinning开启;
-XX:PreBlockSpin=10 为自旋次数;
JDK1.7后,去掉此参数,由jvm控制

2.2.4 重量级锁Synchronized

点此了解synchronized

2.2.5 锁优化

jvm 在加锁的过程中,会采用自旋、自适应、锁消除、锁粗化等优化手段来提升代码执行效率

2.2.5.1 锁消除

锁消除 是指虚拟机即时编译器在运行时检测到某段需要同步的代码不可能存在共享数据竞争而实施的一种对锁进行消除的优化策略。锁消除的主要判断依据于逃逸分析。如果判断一段代码,在堆上所有的数据都不会逃逸出去被别的线程访问到,那就把它当作栈上的数据对待,认为它们是私有的,同步加锁就无需进行。
点击了解JVM中逃逸分析

下面是三个字符串 x, y, z 相加的例子,无论是从源代码上还是逻辑上都没有进行同步

public String concatStr(String x, String y, String z) {
    return  x + y + z;
}

String 是一个不可变的类,对字符的链接总是生成新的 String 对象来进行的,因此 Javac 编译器会对 String 链接进行自动优化,在 jdk5 之前字符串链接会转换为 StringBuffer;在 jdk5 之后会转换为 StringBuilder 对象连续的 append()操作,我们看看 javac 过后,反编译的结果:

public String concatStr(String x, String y, String z) {
    StringBuilder sb = new StringBuilder();
    sb.append(x);
    sb.append(y);
    sb.append(z);
    return  sb.toString();
}

在这里插入图片描述

这里大家可能会担心 StringBuilder 不是线程安全的的操作会存在线程安全的问题吗?这里的答案是不会,x + y + z操作的优化 经过逃逸分析 过后,动态作用域被限制在了 concatStr方法内,就是说当前实际执行的 StringBuilder 的操作在 concatStr 方法内部,其他的外部线程无法访问到,所以这里虽然有锁,但是可以被安全的消除掉。所以当我们进行编译过后,这段代码就会忽略掉所有的同步措施直接执行

2.2.5.2 锁粗化

原则上,我们在写代码的时候,总是推荐将同步块的作用范围限制得尽可能的小--只在共享数据的实际操作作用域中才进行同步,这样也是为了使得需要同步的操作尽可能的变少,即使存在锁的竞争,等待的锁的线程也能很快的获取到锁。大多数情况下,上面的原则都是正确的,但是如果 一系列的连续操作都是对同一个对象反复加锁和解锁,甚至加锁操作时出现在循环体之中 的,那即使没有线程的竞争,频繁的进行相互操作也会导致不必需要的性能损耗

StringBuffer buffer = new StringBuffer();
/**  锁粗化 */
public void append(){
 buffer.append("aaa").append(" bbb").append(" ccc");
}

上面的代码每次调用 buffer.append 方法都需要加锁和解锁,如果 JVM 检测到有一串连续的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁解锁操作,即在第一个 append 方法执行的时候进行加锁,最后一个 append 方法结束后进行解锁。

2.2.6 锁总结

大致执行过程:

  1. 检测Mark Word里面是不是当前线程ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁

锁的优缺点对比

优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程使用自旋会消耗CPU 追求响应时间。同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量。同步块执行速度较长

总结图示如下:
在这里插入图片描述
转载于:https://blog.csdn.net/zqz_zqz/article/details/70233767

posted @ 2021-12-19 20:44  上善若泪  阅读(223)  评论(0编辑  收藏  举报