关于java:java并发原理

14次阅读

共计 15498 个字符,预计需要花费 39 分钟才能阅读完成。

  1. 实践

======

1.1 并发问题

原子性

分时复用引起

可见性

CPU 和缓存引起

有序性

cpu 的乱序执行和编译器的指令重排序引起

  • 编译器优化的重排序。编译器在不扭转单线程程序语义的前提下,能够重新安排语句的执行程序。
  • 指令级并行的重排序。古代处理器采纳了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠
  • 执行。如果不存在数据依赖性,处理器能够扭转语句对应机器指令的执行程序。

1.2 锁

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

乐观锁

乐观锁是一种乐观思维,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为他人不会批改,所以不会上锁,然而在更新的时候会判断一下在此期间他人有没有去更新这个数据,采取在写时先读出以后版本号,而后加锁操作(比拟跟上一次的版本号,如果一样则更新),如果失败则要反复读 - 比拟 - 写的操作。
java 中的乐观锁根本都是通过 CAS 操作实现的,CAS 是一种更新的原子操作,比拟以后值跟传入值是否一样,一样则更新,否则失败。

乐观锁

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

1.3 缓存

缓存一致性

在多种类型微架构平台上,又是如何解决缓存不一致性问题的呢?这是泛滥 CPU 厂商必须解决的问题。为了解决后面提到的缓存数据不统一的问题,人们提出过很多计划,通常来说有以下 2 种计划:

  1. 通过在总线加 LOCK# 锁的形式;
  2. 通过缓存一致性协定(Cache Coherence Protocol);

总线加 Lock

当一个 CPU 对其缓存中的数据进行操作的时候,往总线中发送一个 Lock 信号。其余处理器的申请将会被阻塞,那么该处理器能够独占共享内存。总线锁相当于把 CPU 和内存之间的通信锁住了,所以这种形式会导致 CPU 的性能降落

缓存一致性协定

处理器上有一套残缺的协定,来保障 Cache 的一致性,比拟经典的应该就是 MESI 协定了,它的办法是在 CPU 缓存中保留一个标记位,这个标记为有四种状态

  • M(Modified) 批改缓存,以后 CPU 缓存曾经被批改,示意曾经和内存中的数据不统一了
  • I(Invalid) 生效缓存,阐明 CPU 的缓存曾经不能应用了
  • E(Exclusive) 独占缓存,以后 cpu 的缓存和内存中数据保持一致,而且其余处理器没有缓存该数据
  • S(Shared) 共享缓存,数据和内存中数据统一,并且该数据存在多个 cpu 缓存中

Cache 一致性流量

这要从 SMP(对称多处理器)架构说起,下图大略表明了 SMP 的构造:

其意思是 所有的 CPU 会共享一条系统总线(BUS),靠此总线连贯主存。每个核都有本人的一级缓存,各核绝对于 BUS 对称散布,因而这种构造称为“对称多处理器”。

CAS 的全称为 Compare-And-Swap,是一条 CPU 的原子指令,其作用是让 CPU 比拟后原子地更新某个地位的值,通过考察发现,其实现形式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的

例如:Core1 和 Core2 可能会同时把主存中某个地位的值 Load 到本人的 L1 Cache 中,当 Core1 在本人的 L1 Cache 中批改这个地位的值时,会通过总线,使 Core2 中 L1 Cache 对应的值“生效”,而 Core2 一旦发现自己 L1 Cache 中的值生效(称为 Cache 命中缺失)则会通过总线从内存中加载该地址最新的值,大家 通过总线的来回通信称为“Cache 一致性流量”,因为总线被设计为固定的“通信能力”,如果 Cache 一致性流量过大,总线将成为瓶颈。而当 Core1 和 Core2 中的值再次统一时,称为“Cache 一致性”,从这个层面来说,锁设计的终极目标便是缩小 Cache 一致性流量。

而 CAS 恰好会导致 Cache 一致性流量,如果有很多线程都共享同一个对象,当某个 Core CAS 胜利时必然会引起总线风暴,这就是所谓的本地提早,实质上偏差锁就是为了打消 CAS,升高 Cache 一致性流量。

1.4 cas

CAS 的全称为 Compare-And-Swap,直译就是比照替换。是一条 CPU 的原子指令 cmpxchgl,其作用是让 CPU 先进行比拟两个值是否相等,而后原子地更新某个地位的值,通过考察发现,其实现形式是基于硬件平台的汇编指令,就是说 CAS 是靠硬件实现的,JVM 只是封装了汇编调用,那些 AtomicInteger 类便是应用了这些封装后的接口。简略解释:CAS 操作须要输出两个数值,一个旧值 (冀望操作前的值) 和一个新值,在操作期间先比拟下在旧值有没有发生变化,如果没有发生变化,才替换成新值,产生了变动则不替换。

CAS 形式为乐观锁,synchronized 为乐观锁。因而应用 CAS 解决并发问题通常状况下性能更优。

但应用 CAS 形式也会有几个问题:

  • ABA 问题

因为 CAS 须要在操作值的时候,查看值有没有发生变化,比方没有发生变化则更新,然而如果一个值原来是 A,变成了 B,又变成了 A,那么应用 CAS 进行查看时则会发现它的值没有发生变化,然而实际上却变动了。

ABA 问题的解决思路就是应用版本号。在变量后面追加上版本号,每次变量更新的时候把版本号加 1,那么 A ->B->A 就会变成 1A->2B->3A。

  1. JVM 模型

=========

2.1 java 内存模型

并发模型

在并发编程中,须要解决两个关键问题:

  • 线程之间如何通信;
  • 线程之间如何同步;

线程通信是指线程之间以何种机制来替换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。

  • 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信。
  • 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送音讯来显式进行通信。

线程同步是指程序用于管制不同线程之间操作产生绝对程序的机制。

  • 在共享内存的并发模型里,同步是显式进行的。程序员必须显式指定某个办法或某段代码须要在线程之间互斥执行。
  • 在消息传递的并发模型里,因为音讯的发送必须在音讯的接管之前,因而同步是隐式进行的。

Java 的并发采纳的是共享内存模型,Java 线程之间的通信总是隐式进行,整个通信过程对程序员齐全通明。如果你想设计体现良好的并发程序,了解 Java 内存模型是十分重要的。Java 内存模型规定了如何和何时能够看到由其余线程批改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM

缓存一致性问题、处理器器优化的指令重排问题是硬件的一直降级导致的。那么,有没有什么机制能够很好的解决下面的这些问题呢?
最简略间接的做法就是破除处理器和处理器的优化技术、破除 CPU 缓存,让 CPU 间接和主存交互。然而,这么做尽管能够保障多线程下的并发问题。然而,这就有点因噎废食了。
所以,为了保障并发编程中能够满足原子性、可见性及有序性。有一个重要的概念,那就是——内存模型,定义了共享内存零碎中多线程程序读写操作行为的标准。
通过这些规定来标准对内存的读写操作,从而保障指令执行的正确性。它与处理器无关、与缓存无关、与并发无关、与编译器也无关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的内存拜访问题,保障了并发场景下的一致性、原子性和有序性。

Java 内存模型(Java Memory Model ,JMM)就是一种合乎内存模型 标准 的,屏蔽了各种硬件和操作系统的拜访差别的,保障了 Java 程序在各种平台下对内存的拜访都能保障成果统一的机制及标准。Java 内存模型规定了

  • 所有的变量都存储在主内存中
  • 每条线程还有本人的工作内存,线程的工作内存中保留了该线程中用到的变量的主内存正本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能间接读写主内存。
  • 不同的线程之间也无奈间接拜访对方工作内存中的变量,线程间变量的传递均须要本人的工作内存和主存之间进行数据同步进行。

而 JMM 就作用于工作内存和主存之间数据同步过程。他规定了如何做数据同步以及什么时候做数据同步。特地须要留神的是,主内存和工作内存与 JVM 内存构造中的 Java 堆、栈、办法区等并不是同一个档次的内存划分,无奈间接类比。再来总结下,JMM 是一种标准,标准了 Java 虚拟机与计算机内存是如何协同工作的,目标是解决因为多线程通过共享内存进行通信时,存在的本地内存数据不统一、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目标是保障并发编程场景中的原子性、可见性和有序性。
所以,如果你想设计体现良好的并发程序,了解 Java 内存模型是十分重要的。Java 内存模型规定了如何和何时能够看到由其余线程批改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

JMM 形象

在 Java 中,所有实例域、动态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量(Local variables),办法定义参数(formal method parameters)和异样处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
Java 线程之间的通信由 Java 内存模型(JMM)管制,JMM 决定一个线程对共享变量的写入何时对另一个线程可见。从形象的角度来看,JMM 定义了线程和主内存之间的形象关系:

线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个公有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的正本。本地内存是 JMM 的一个抽象概念,并不实在存在。它涵盖了缓存,写缓冲区,寄存器以及其余的硬件和编译器优化。

从上图来看,线程 A 与线程 B 之间如要通信的话,必须要经验上面 2 个步骤:

首先,线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去;而后,线程 B 到主内存中去读取线程 A 之前已更新过的共享变量;

内存屏障

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

原语

java 内存模型通知咱们通过应用关键词“synchronized”或“volatile”能够让 Java 保障某些束缚:

  • “volatile”— 保障读写的都是主内存的变量。
  • “synchronized”— 保障在块开始时都同步主内存的值到工作内存,而块完结时将变量同步回主内存。

所以,在编译器各种优化及多种类型的微架构平台上,Java 语言标准制定者试图创立一个虚构的概念并传递到 Java 程序员,让他们可能在这个虚构的概念上写出线程平安的程序来,而编译器实现者会依据 Java 语言标准中的各种束缚在不同的平台上达到 Java 程序员所须要的线程平安这个目标。

2.2 java 对象模型

对象构造

在 HotSpot 虚拟机中,对象在内存中存储的布局能够分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。下图是一般对象实例与数组对象实例的数据结构:

对象头

HotSpot 虚拟机的对象头包含两局部信息:

  • markword
    第一局部 markword, 用于存储对象本身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标记、线程持有的锁、偏差线程 ID、偏差工夫戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中别离为 32bit 和 64bit,官网称它为“MarkWord”。
  • klass
    对象头的另外一部分是 klass 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例.
  • 数组长度(只有数组对象有)
    如果对象是一个数组, 那在对象头中还必须有一块数据用于记录数组长度.

实例数据

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

对齐填充

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

对象大小计算

要点

  1. 在 32 位零碎下,寄存 Class 指针的空间大小是 4 字节,MarkWord 是 4 字节,对象头为 8 字节。
  2. 在 64 位零碎下,寄存 Class 指针的空间大小是 8 字节,MarkWord 是 8 字节,对象头为 16 字节。
  3. 64 位开启指针压缩的状况下,寄存 Class 指针的空间大小是 4 字节,MarkWord 是 8 字节,对象头为 12 字节。数组长度 4 字节 + 数组对象头 8 字节(对象援用 4 字节(未开启指针压缩的 64 位为 8 字节)+ 数组 markword 为 4 字节(64 位未开启指针压缩的为 8 字节))+ 对齐 4 =16 字节。
  4. 动态属性不算在对象大小内。

markword

markword 在不同锁状态下的构造 markword 的最初两位存储了锁的标记位,01 是初始状态,未加锁,其对象头里存储的是对象自身的哈希码,随着锁级别的不同,对象头里会存储不同的内容。
偏差锁存储的是以后占用此对象的线程 ID;
而轻量级则存储指向线程栈中锁记录的指针。
从这里咱们能够看到,“锁”这个货色,可能是个锁记录 + 对象头里的援用指针(判断线程是否领有锁时将线程的锁记录地址和对象头里的指针地址比拟),也可能是对象头里的线程 ID(判断线程是否领有锁时将线程的 ID 和对象头里存储的线程 ID 比拟)。

线程栈的 Lock Record

在线程进入同步代码块的时候,如果此同步对象没有被锁定,即它的锁标记位是 01,则虚拟机首先在以后线程的栈中创立咱们称之为“锁记录(Lock Record)”的空间,用于存储锁对象的 Mark Word 的拷贝,官网把这个拷贝称为 Displaced Mark Word。整个 Mark Word 及其拷贝至关重要。
Lock Record 是线程公有的数据结构,每一个线程都有一个可用 Lock Record 列表,同时还有一个全局的可用列表。每一个被锁住的对象 Mark Word 都会和一个 Lock Record 关联(对象头的 MarkWord 中的 Lock Word 指向 Lock Record 的起始地址),同时 Lock Record 中有一个 Owner 字段寄存领有该锁的线程的惟一标识(或者 object mark word),示意该锁被这个线程占用。

将 object mark word 里的轻量级锁指针指向 lock record 所在的 stack 指针,作用是让其余线程晓得,该 object monitor 已被占用。lock record 里的 owner 指针指向 object mark word 的作用是为了在接下里的运行过程中,辨认哪个对象被锁住了。

jvm unlock 同样应用了 CAS 来验证 object mark word 在持有锁到开释锁之间,有无被其余线程拜访。如果其余线程在持有锁这段时间里,尝试获取过锁,则可能本身被挂起,而 mark word 的重量级锁指针也会被相应批改。此时,unlock 后就须要唤醒被挂起的线程。

为什么降级为轻量锁时要把对象头里的 Mark Word 复制到线程栈的锁记录中呢?因为在申请对象锁时 须要以该值作为 CAS 的比拟条件,同时在降级到重量级锁的时候,能通过这个比拟断定是否在持有锁的过程中此锁被其余线程申请过,如果被其余线程申请了,则在开释锁的时候要唤醒被挂起的线程。

  1. JVM 实现

=========

3.1 volatile

  • 作用:可见性和避免指令重排序
  • 实现:字节码:ACC_VOLATILE,
    JVM: 内存屏障:loadloadbarrier/loadstorebarrier/storeloadbarrier/storestorebarrier
    操作系统:lock 指令

打消缓存行的伪共享

除了咱们在代码中应用的同步锁和 jvm 本人内置的同步锁外,还有一种暗藏的锁就是缓存行,它也被称为性能杀手。
在多核 cup 的处理器中,每个 cup 都有本人独占的一级缓存、二级缓存,甚至还有一个共享的三级缓存,为了进步性能,cpu 读写数据是以缓存行为最小单元读写的;32 位的 cpu 缓存行为 32 字节,64 位 cup 的缓存行为 64 字节 ,这就导致了一些问题。
例如,多个不须要同步的变量因为存储在间断的 32 字节或 64 字节外面,当须要其中的一个变量时,就将它们作为一个缓存行一起加载到某个 cup- 1 公有的缓存中(尽管只须要一个变量,然而 cpu 读取会以缓存行为最小单位,将其相邻的变量一起读入),被读入 cpu 缓存的变量相当于是对主内存变量的一个拷贝,也相当于变相的将在同一个缓存行中的几个变量加了一把锁,这个缓存行中任何一个变量产生了变动,当 cup- 2 须要读取这个缓存行时,就须要先将 cup- 1 中被扭转了的整个缓存行更新回主存(即便其它变量没有更改),而后 cup- 2 才可能读取,而 cup- 2 可能须要更改这个缓存行的变量与 cpu- 1 曾经更改的缓存行中的变量是不一样的,所以这相当于给几个息息相关的变量加了一把同步锁;
为了避免伪共享,不同 jdk 版本实现形式是不一样的:

  1. 在 jdk1.7 之前会 将须要独占缓存行的变量前后增加一组 long 类型的变量,依附这些无意义的数组的填充做到一个变量本人独占一个缓存行;
  2. 在 jdk1.7 因为 jvm 会将这些没有用到的变量优化掉,所以采纳继承一个申明了好多 long 变量的类的形式来实现;
  3. 在 jdk1.8 中通过增加 sun.misc.Contended 注解来解决这个问题,若要使该注解无效必须在 jvm 中增加以下参数:-XX:-RestrictContended sun.misc.Contended 注解会在变量后面增加 128 字节的 padding 将以后变量与其余变量进行隔离

3.2 synchronized

作用:原子性
实现:
 字节码:monitorenter/monitorexit
 jvm: 锁降级
  new
  偏差锁
   对象头的 markword 标记线程 id
  自旋锁 / 轻量级锁 / 无锁
   本地线程会在栈中生成一个 record,cas 形式存入对象头
   aba 问题 -version
  重量级锁
   操作系统级别的线程期待 操作系统:lock comxchg 指令

  • 三种应用形式
  1. 润饰实例办法,作用于以后实例加锁,进入同步代码前要取得以后实例的锁
  2. 静态方法,作用于以后类对象加锁,进入同步代码前要取得以后类对象的锁
  3. 润饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要取得给定对象的锁。

锁的类型

轻量级锁是为了在线程交替执行同步块时进步性能,而偏差锁则是在只有一个线程执行同步块时进一步提高性能。

偏差锁

偏差锁是 JDK6 中的重要引进,因为 HotSpot 作者通过钻研实际发现,在大多数状况下,锁不仅不存在多线程竞争,而且总是由同一线程屡次取得,为了让线程取得锁的代价更低,引进了偏差锁。
偏差锁是在单线程执行代码块时应用的机制,如果在多线程并发的环境下(即线程 A 尚未执行完同步代码块,线程 B 发动了申请锁的申请),则肯定会转化为轻量级锁或者重量级锁。
引入偏差锁次要目标是:为了在没有多线程竞争的状况下尽量减少不必要的轻量级锁执行门路。因为轻量级锁的加锁解锁操作是须要依赖屡次 CAS 原子指令的,而偏差锁只须要在置换 ThreadID 的时候依赖一次 CAS 原子指令(因为一旦呈现多线程竞争的状况就必须撤销偏差锁,所以偏差锁的撤销操作的性能损耗也必须小于节省下来的 CAS 原子指令的性能耗费)。

偏差锁的获取

当一个线程拜访同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏差的线程 ID,当前该线程进入和退出同步块时不须要破费 CAS 操作来抢夺锁资源,只须要查看是否为偏差锁、锁标识为以及 ThreadID 即可,解决流程如下:

  1. 检测 Mark Word 是否为可偏差状态,即是否为偏差锁 1,锁标识位为 01;
  2. 若为可偏差状态,则测试线程 ID 是否为以后线程 ID,如果是,则执行步骤(5),否则执行步骤(3);
  3. 如果测试线程 ID 不为以后线程 ID,则通过 CAS 操作竞争锁,竞争胜利,则将 Mark Word 的线程 ID 替换为以后线程 ID,否则执行线程(4);
  4. 通过 CAS 竞争锁失败,证实以后存在多线程竞争状况,当达到全局平安点,取得偏差锁的线程被挂起,偏差锁降级为轻量级锁,而后被阻塞在平安点的线程持续往下执行同步代码块;
  5. 执行同步代码块;
偏差锁的撤销

偏差锁的开释采纳了 一种只有竞争才会开释锁的机制,线程是不会被动去开释偏差锁,须要期待其余线程来竞争。偏差锁的撤销须要 期待全局平安点(这个工夫点是上没有正在执行的代码)。其步骤如下:

  1. 暂停领有偏差锁的线程;
  2. 判断锁对象是否还处于被锁定状态
    否,则复原到无锁状态(01),以容许其余线程竞争。
    是,则挂起持有锁的以后线程,并将指向以后线程的锁记录地址的指针放入对象头 Mark Word,降级为轻量级锁状态(00),而后复原持有锁的以后线程,进入轻量级锁的竞争模式;

轻量级锁

对于轻量级锁,其性能晋升的根据是“对于绝大部分的锁,在整个生命周期内都是不会存在竞争的”,如果突破这个根据则除了互斥的开销外,还有额定的 CAS 操作,因而在有多线程竞争的状况下,轻量级锁比重量级锁更慢。

轻量级锁的获取

引入轻量级锁的次要目标是 在没有多线程竞争的前提下,缩小传统的重量级锁应用操作系统互斥量产生的性能耗费。当敞开偏差锁性能或者多个线程竞争偏差锁导致偏差锁降级为轻量级锁,则会尝试获取轻量级锁,其步骤如下:

  1. 在线程进入同步块时,如果同步对象锁状态为无锁状态(锁标记位为“01”状态,是否为偏差锁为“0”),虚拟机首先将在以后线程的栈帧中建设一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官网称之为 Displaced Mark Word。此时线程堆栈与对象头的状态如下图所示:
  2. 拷贝对象头中的 Mark Word 复制到锁记录(Lock Record)中;
  3. 拷贝胜利后,虚拟机将应用 CAS 操作尝试将对象 Mark Word 中的 Lock Word 更新为指向以后线程 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。如果更新胜利,则执行步骤(4),否则执行步骤(5);
  4. 如果这个更新动作胜利了,那么以后线程就领有了该对象的锁,并且对象 Mark Word 的锁标记位设置为“00”,即示意此对象处于轻量级锁定状态,此时线程堆栈与对象头的状态如下图所示:
  5. 如果这个更新操作失败了,虚拟机首先会查看对象 Mark Word 中的 Lock Word 是否指向以后线程的栈帧,如果是,就阐明以后线程曾经领有了这个对象的锁,那就能够间接进入同步块继续执行。否则阐明多个线程竞争锁,进入自旋执行(3),若自旋完结时仍未取得锁,轻量级锁就要收缩为重量级锁,锁标记的状态值变为“10”,Mark Word 中存储的就是指向重量级锁(互斥量)的指针,以后线程以及前面期待锁的线程也要进入阻塞状态。
轻量级锁的开释

轻量级锁的开释也是通过 CAS 操作来进行的,次要步骤如下:

  1. 通过 CAS 操作尝试把线程中复制的 Displaced Mark Word 对象替换以后的 Mark Word;
  2. 如果替换胜利,整个同步过程就实现了,复原到无锁状态(01);
  3. 如果替换失败,阐明有其余线程尝试过获取该锁(此时锁已收缩),那就要在开释锁的同时,唤醒被挂起的线程;

重量级锁

Synchronized 是通过对象外部的一个叫做 监视器锁(Monitor)来实现的。然而监视器锁实质又是依赖于底层的操作系统的 Mutex Lock 来实现的。而操作系统实现线程之间的切换这就须要从用户态转换到外围态,这个老本十分高,状态之间的转换须要绝对比拟长的工夫,这就是为什么 Synchronized 效率低的起因。因而,这种依赖于操作系统 Mutex Lock 所实现的锁咱们称之为“重量级锁”。

锁降级

锁收缩方向:无锁 → 偏差锁 → 轻量级锁 → 重量级锁 (此过程是不可逆的)

锁的降级是单向的,也就是说只能从低到高降级,不会呈现锁的降级。
在 JDK 1.6 中默认是开启偏差锁和轻量级锁的,能够通过 -XX:-UseBiasedLocking 来禁用偏差锁。

自旋锁在 JDK 1.4.2 中引入,默认敞开,然而能够应用 -XX:+UseSpinning 开开启,在 JDK1.6 中默认开启。同时自旋的默认次数为 10 次,能够通过参数 -XX:PreBlockSpin 来调整。
JDK1.6 引入自适应的自旋锁,让虚构机会变得越来越聪慧

锁打消

在有些状况下,JVM 检测到不可能存在共享数据竞争,这是 JVM 会对这些同步锁进行锁打消。
在运行这段代码时,JVM 能够显著检测到变量 vector 没有逃逸出办法 vectorTest()之外,所以 JVM 能够大胆地将 vector 外部的加锁操作打消。

public void vectorTest(){Vector<String> vector = new Vector<String>();
    for(int i = 0 ; i < 10 ; i++){vector.add(i + "");
    }

    System.out.println(vector);
}
复制代码

锁粗化

  • 原则上,咱们都晓得在加同步锁时,尽可能的将同步块的作用范畴限度到尽量小的范畴(只在共享数据的理论作用域中才进行同步,这样是为了使得须要同步的操作数量尽可能变小。在存在锁同步竞争中,也能够使得期待锁的线程尽早的拿到锁)。
  • 大部分上述情况是完满正确的,然而如果存在连串的一系列操作都对同一个对象重复加锁和解锁,甚至加锁操作时呈现在循环体中的,那即便没有线程竞争,频繁地进行互斥同步操作也会导致不必要地性能操作。
public static String test04(String s1, String s2, String s3) {StringBuilder sb = new StringBuilder();
    sb.append(s1);
    sb.append(s2);
    sb.append(s3);
    return sb.toString();}
复制代码

在上述地间断 append()操作中就属于这类状况。JVM 会检测到这样一连串地操作都是对同一个对象加锁,那么 JVM 会将加锁同步地范畴扩大 (粗化) 到整个一系列操作的 内部,使整个一连串地 append()操作只须要加锁一次就能够了。

3.3 wait/notify

wait/notify 取得 synchronize 锁之后,调用 wait 会让以后线程进入期待队列并且开释锁 取得 synchronize 锁之后,调用 notify 会告诉其余线程能够竞争 synchronize 锁,但并不会开释锁,须要等到 monitorexit 之后才会开释锁

Thread.sleep()和 Object.wait()的区别

  • Thread.sleep()不会开释占有的锁,Object.wait()会开释占有的锁;
  • Thread.sleep()必须传入工夫,Object.wait()可传可不传,不传示意始终阻塞上来;
  • Thread.sleep()到工夫了会主动唤醒,而后继续执行;
  • Object.wait()不带工夫的,须要另一个线程应用 Object.notify()唤醒;
  • Object.wait()带工夫的,如果没有被 notify,到工夫了会主动唤醒,这时又分好两种状况,一是立刻获取到了锁,线程天然会继续执行;二是没有立刻获取锁,线程进入同步队列期待获取锁;

其实,他们俩最大的区别就是 Thread.sleep()不会开释锁资源,Object.wait()会开释锁资源。

3.4 Unsafe

cas

Unsafe 只提供了 3 种 CAS 办法:compareAndSwapObject、compareAndSwapInt 和 compareAndSwapLong。都是 native 办法。

public final native boolean compareAndSwapObject(Object paramObject1, long paramLong, Object paramObject2, Object paramObject3);

public final native boolean compareAndSwapInt(Object paramObject, long paramLong, int paramInt1, int paramInt2);

public final native boolean compareAndSwapLong(Object paramObject, long paramLong1, long paramLong2, long paramLong3);
复制代码

无妨再看看 Unsafe 的 compareAndSwap 办法来实现 CAS 操作,它是一个本地办法,实现位于 unsafe.cpp 中。

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END
复制代码

能够看到它通过 Atomic::cmpxchg 来实现比拟和替换操作。其中参数 x 是行将更新的值,参数 e 是原内存的值。

如果是 Linux 的 x86,Atomic::cmpxchg 办法的实现如下:

inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}
复制代码

park/unpark

park/unpark 可能精准的对线程进行唤醒和期待。
linux 上的实现是通过 POSIX 的线程 API 的期待、唤醒、互斥、条件来进行实现的
park 在执行过程中首选看是否有许可,有许可就立马返回,而每次 unpark 都会给许可设置成有,这意味着,能够先执行 unpark,给予许可,再执行 park 立马自行,实用于 producer 快,而 consumer 还未实现的场景

 public native void unpark(Object var1);
    public native void park(boolean var1, long var2);
复制代码

parker 实现如下

void Parker::park(bool isAbsolute, jlong time) {if (_counter > 0) {
       // 曾经有许可了,用掉以后许可
      _counter = 0 ;
     // 应用内存屏障,确保 _counter 赋值为 0(写入操作)可能被内存屏障之后的读操作获取内存屏障事先的后果,也就是可能正确的读到 0
      OrderAccess::fence();
     // 立刻返回
      return ;
  }

  Thread* thread = Thread::current();
  assert(thread->is_Java_thread(), "Must be JavaThread");
  JavaThread *jt = (JavaThread *)thread;

 if (Thread::is_interrupted(thread, false)) {
 // 线程执行了中断,返回
    return;
  }

  if (time < 0 || (isAbsolute && time == 0) ) { 
    // 工夫到了,或者是代表相对工夫,同时相对工夫是 0(此时也是工夫到了),间接返回,java 中的 parkUtil 传的就是相对工夫,其它都不是
   return;
  }
  if (time > 0) {// 传入了工夫参数,将其存入 absTime,并解析成 absTime->tv_sec(秒)和 absTime->tv_nsec(纳秒)存储起来,存的是相对工夫
    unpackTime(&absTime, isAbsolute, time);
  }

 // 进入 safepoint region,更改线程为阻塞状态
  ThreadBlockInVM tbivm(jt);

 if (Thread::is_interrupted(thread, false) || pthread_mutex_trylock(_mutex) != 0) {
  // 如果线程被中断,或者是在尝试给互斥变量加锁的过程中,加锁失败,比方被其它线程锁住了,间接返回
    return;
  }
// 这里示意线程互斥变量锁胜利了
  int status ;
  if (_counter > 0)  {
    // 有许可了,返回
    _counter = 0;
    // 对互斥变量解锁
    status = pthread_mutex_unlock(_mutex);
    assert (status == 0, "invariant") ;
    OrderAccess::fence();
    return;
  }
复制代码

当 unpark 时,则简略多了,间接设置_counter 为 1,再 unlock mutex 返回。如果_counter 之前的值是 0,则还要调用 pthread_cond_signal 唤醒在 park 中期待的线程:

void Parker::unpark() {
  int s, status ;
 // 给互斥量加锁,如果互斥量曾经上锁,则阻塞到互斥量被解锁
//park 进入 wait 时,_mutex 会被开释
  status = pthread_mutex_lock(_mutex);
  assert (status == 0, "invariant") ; 
  // 存储旧的_counter
  s = _counter; 
// 许可改为 1,每次调用都设置成发放许可
  _counter = 1;
  if (s < 1) {
     // 之前没有许可
     if (WorkAroundNPTLTimedWaitHang) {
      // 默认执行 , 开释信号,表明条件曾经满足,将唤醒期待的线程
        status = pthread_cond_signal (_cond) ;
        assert (status == 0, "invariant") ;
        // 开释锁
        status = pthread_mutex_unlock(_mutex);
        assert (status == 0, "invariant") ;
     } else {status = pthread_mutex_unlock(_mutex);
        assert (status == 0, "invariant") ;
        status = pthread_cond_signal (_cond) ;
        assert (status == 0, "invariant") ;
     }
  } else {
   // 始终有许可,开释掉本人加的锁, 有许可 park 自身就返回了
    pthread_mutex_unlock(_mutex);
    assert (status == 0, "invariant") ;
  }
}
复制代码
  1. 锁优化

=======

4.1 缩小锁的工夫

不须要同步执行的代码,能不放在同步快外面执行就不要放在同步快内,能够让锁尽快开释;

4.2 缩小锁的粒度

它的思维是将物理上的一个锁,拆成逻辑上的多个锁,减少并行度,从而升高锁竞争。它的思维也是用空间来换工夫;拆锁的粒度不能有限拆,最多能够将一个锁拆为以后 cup 数量个锁即可

ConcurrentHashMap

java 中的 ConcurrentHashMap 在 jdk1.8 之前的版本,应用一个 Segment 数组 Segment< K,V >[] segments Segment 继承自 ReenTrantLock,所以每个 Segment 就是个可重入锁,每个 Segment 有一个 HashEntry< K,V > 数组用来存放数据,put 操作时,先确定往哪个 Segment 放数据,只须要锁定这个 Segment,执行 put,其它的 Segment 不会被锁定;所以数组中有多少个 Segment 就容许同一时刻多少个线程存放数据,这样减少了并发能力。

LinkedBlockingQueue

LinkedBlockingQueue 也体现了这样的思维,在队列头入队,在队列尾出队,入队和出队应用不同的锁,绝对于 LinkedBlockingArray 只有一个锁效率要高;

4.3 应用读写锁

ReentrantReadWriteLock 是一个读写锁,读操作加读锁,能够并发读,写操作应用写锁,只能单线程写;

4.4 读写拆散

CopyOnWriteArrayList、CopyOnWriteArraySet

  • CopyOnWrite 容器即写时复制的容器。艰深的了解是当咱们往一个容器增加元素的时候,不间接往以后容器增加,而是先将以后容器进行 Copy,复制出一个新的容器,而后新的容器里增加元素,增加完元素之后,再将原容器的援用指向新的容器。这样做的益处是咱们能够对 CopyOnWrite 容器进行并发的读,而不须要加锁,因为以后容器不会增加任何元素。所以 CopyOnWrite 容器也是一种读写拆散的思维,读和写不同的容器。
  • CopyOnWrite 并发容器用于读多写少的并发场景,因为,读的时候没有锁,然而对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个正本,各自批改各自的;

4.5 cas

如果须要同步的操作执行速度十分快,并且线程竞争并不强烈,这时候应用 cas 效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作自身更耗时,且线程对资源的竞争不强烈,应用 volatiled+cas 操作会是十分高效的抉择;

  1. 代码示例

========

github.com/ns7381/java…

参考:《2020 最新 Java 根底精讲视频教程和学习路线!》

链接:https://juejin.cn/post/693620…

正文完
 0