笔记——并发之JVM篇

Java内存模型

内存模型,它可以理解为在特定的操作协议下, 对特定的内存或高速缓存进行读写访问的过程抽象。 不同架构的物理机器可以拥有不一样的内存模型, 而Java虚拟机也有自己的内存模型, 并且与这里介绍的内存访问操作及硬件的缓存访问操作具有高度的可类比性。

Java 内存模型试图屏蔽各种硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台下都能达到一致的内存访问效果。

高速缓存

处理器运算速度比内存存取速度高了几个数量级,如果一边读一遍运算会使效率极低,于是加入高速缓存作为内存与处理器之间的缓冲,处理器不直接和内存进行通讯,而是将内存的数据独到内部缓存后再进行操作。高速缓存的读写速度会尽可能接近处理器运算速度。

将运算需要使用的数据复制到缓存中, 让运算能快速进行,当运算结束后再从缓存同步回内存之中, 这样处理器就无须等待缓慢的内存读写了。

但因为有多个处理器于是也就有多个高速缓存,如果多个处理器的运算任务都涉及同一块主内存区域,就有可能出现缓存数据不一致,待缓存同步回内存时出现冲突。为解决一致性问题,需要处理器遵循缓存一致性协议,在读写时要根据协议来进行操作。
在这里插入图片描述

乱序执行优化

为了使处理器内部的运算单元能尽量被充分利用, 处理器可能会对输入代码进行乱序执行优化, 处理器会在计算之后将乱序执行的结果重组, 保证该结果与顺序执行的结果是一致的, 但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致, 因此如果存在一个计算任务依赖另外一个计算任务的中间结果, 那么其顺序性并不能靠代码的先后顺序来保证。

与处理器的乱序执行优化类似, Java虚拟机的即时编译器中也有指令重排序优化

Java内存模型:主内存与工作内存

在这里插入图片描述

内存交互

如果对一个变量执行lock操作, 那将会清空工作内存中此变量的值, 在执行引擎使用这个变量
前, 需要重新执行load或assign操作以初始化变量的值

为什么对一个变量执行lock操作, 那将会清空工作内存中此变量的值?

volatile

可见性

volatile保证被修饰的变量对所有线程的可见性, 这里的“可见性”是指当一条线程修改了这个变量的值, 新值对于其他线程来说是可以立即得知的。 而普通变量并不能做到这一点, 普通变量的值在线程间传递时均需要通过主内存来完成。

禁止指令重排序

禁止指令重排序优化, 普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果, 而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。 因为在同一个线程的方法执行过程中无法感知到这点, 这就是Java内存模型中描述的所谓“线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics) 。

第二个操作能否重排到第一个操作前:
在这里插入图片描述

  • 第一个为读操作时,第二个任何操作不可重排序到第一个前面。
  • 第二个为写操作时,第一个任何操作不可重排序到第二个后面。
  • 第一个为写操作时,第二个的读写操作也不运行重排序。

禁止指令重排序的实现是内存屏障。如果一个变量是volatile修饰的,JMM(Java Memory Model)会在它的写操作之后插进一个Write-Barrier指令,并在它的读操作之前插入一个Read-Barrier指令。

线程安全

不可变、 绝对线程安全、 相对线程安全、 线程兼容和线程对立。

互斥同步

同步是指在多个线程并发访问共享数据时, 保证共享数据在同一个时刻只被一条(或者是一些,当使用信号量的时候) 线程使用。 而互斥是实现同步的一种手段, 临界区(Critical Section) 、 互斥量(Mutex) 和信号量(Semaphore) 都是常见的互斥实现方式。 因此在“互斥同步”这四个字里面, 互斥是因, 同步是果; 互斥是方法, 同步是目的。

非阻塞同步

基于冲突检测的乐观并发策略, 通俗地说就是不管风险, 先进行操作, 如果没有其他线程争用共享数据, 那操作就直接成功了; 如果共享的数据的确被争用, 产生了冲突, 那再进行其他的补偿措施, 最常用的补偿措施是不断地重试, 直到出现没有竞争的共享数据为止。 这种乐观并发策略的实现不再需要把线程阻塞挂起, 因此这种同步操作被称为非阻塞同步(Non-Blocking Synchronization) , 使用这种措施的代码也常被称为无锁(Lock-Free)编程。

乐观并发策略需要“硬件指令集的发展”。因为我们必须要求操作和冲突检测这两个步骤具备原子性。 靠什么来保证原子性? 如果这里再使用互斥同步来保证就完全失去意义了, 所以我们只能靠硬件来实现这件事情。

CAS指令需要有三个操作数, 分别是内存位置(在Java中可以简单地理解为变量的内存地址, 用V表示) 、 旧的预期值(用A表示) 和准备设置的新值(用B表示) 。 CAS指令执行时, 当且仅当V符合A时, 处理器才会用B更新V的值, 否则它就不执行更新。 但是, 不管是否更新了V的值, 都会返回V的旧值, 上述的处理过程是一个原子操作, 执行期间不会被其他线程中断。

使用AtomicInteger代替int后,使用其incrementAndGet(),在多个线程中调用该方法,并不会造成变量得运算结果出现错误比如小了。这一切都要归功于incrementAndGet()方法的原子性。也就是CAS指令。

扩展阅读:
CAS从语义上来说并不是真正完美的, 它存在一个逻辑漏洞: 如果一个变量V初次读取的时候是A值, 并且在准备赋值的时候检查到它仍然为A值, 那就能说明它的值没有被其他线程改变过了吗? 这是不能的, 因为如果在这段期间它的值曾经被改成B, 后来又被改回为A, 那CAS操作就会误认为它从来没有被改变过。 这个漏洞称为CAS操作的“ABA问题”。 J.U.C包为了解决这个问题, 提供了一个带有标记的原子引用类AtomicStampedReference, 它可以通过控制变量值的版本来保证CAS的正确性。 不过目前来说这个类处于相当鸡肋的位置, 大部分情况下ABA问题不会影响程序并发的正确性, 如果需要解决ABA问题, 改用传统的互斥同步可能会比原子类更为高效

无同步方案

有一些代码天生就是线程安全的。

可重入代码(Reentrant Code)

这种代码又称纯代码(Pure Code) , 是指可以在代码执行的任何时刻中断它, 转而去执行另外一段代码(包括递归调用它本身) , 而在控制权返回后, 原来的程序不会出现任何错误, 也不会对结果有所影响。 在特指多线程的上下文语境里(不涉及信号量等因素) , 我们可以认为可重入代码是线程安全代码的一个真子集, 这意味着相对线程安全来说, 可重入性是更为基础的特性, 它可以保证代码线程安全, 即所有可重入的代码都是线程安全的, 但并非所有的线程安全的代码都是可重入的。

可重入代码有一些共同的特征, 例如, 不依赖全局变量、 存储在堆上的数据和公用的系统资源,用到的状态量都由参数中传入, 不调用非可重入的方法等。 我们可以通过一个比较简单的原则来判断代码是否具备可重入性: 如果一个方法的返回结果是可以预测的, 只要输入了相同的数据, 就都能返回相同的结果, 那它就满足可重入性的要求, 当然也就是线程安全的。

线程本地存储(Thread Local Storage)

如果一段代码中所需要的数据必须与其他代码共享, 那就看看这些共享数据的代码是否能保证在同一个线程中执行。 如果能保证, 我们就可以把共享数据的可见范围限制在同一个线程之内, 这样, 无须同步也能保证线程之间不出现数据争用的问题。

符合这种特点的应用并不少见, 大部分使用消费队列的架构模式(如“生产者-消费者”模式) 都会将产品的消费过程限制在一个线程中消费完, 其中最重要的一种应用实例就是经典Web交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request) 的处理方式, 这种处理方式的广泛应用使得很多Web服务端应用都可以使用线程本地存储来解决线程安全问题。

重量级锁

重量级锁Synchronized是作为被优化的对象的,所以也得先介绍

深入理解synchronize
关于锁的那点事儿
Java锁—偏向锁、轻量级锁、自旋锁、重量级锁
监视器锁 synchronized

Synchronized的作用

synchronized可以把任意一个非NULL的对象当作锁。

堆中的java对象头信息

JDK 1.6 引入了偏向锁和轻量级锁,从而让锁拥有了四个状态:无锁状态(unlocked)、偏向锁状态(biasble)、轻量级锁状态(lightweight locked)和重量级锁状态(inflated)。

由于对象头信息是与对象自身定义的数据无关的额外存储成本, 考虑到Java虚拟机的空间使用效率, Mark Word被设计成一个非固定的动态数据结构, 以便在极小的空间内存储尽量多的信息。 它会根据对象的状态复用自己的存储空间。
在这里插入图片描述
扩展阅读:java的方法和对象的栈内存及堆内存的区分?

  • 方法:当一个方法执行时,该方法都会建立自己的内存栈,在该方法内定义的变量将会逐个放入内存栈中,随着方法执行结束,该方法的内存栈也将自然销毁.因此,所有在方法中定义的局部变量都是放在栈内存中的;
  • 对象:创建一个对象时,该对象保存到堆内存(运行时数据区)中,以便反复使用.堆内存中的对象不会随方法的结束而销毁,即使方法结束后,这个对象还可能被另一个引用变量所引用,则这个对象依然不会被销毁,只有当一个对象没有任何引用变量引用它时,系统的垃圾回收器才会在合适的时候回收它.

Synchronized的实现

synchronized基于monitor实现(lock的底层是Monitor来实现的,所以Monitor可以实现lock的所有功能)

synchronized相关联的对象锁,锁标识为10,其中指针指向monitor对象(也称之为管程或者监视器锁)的起始地址

监视器和锁在Java虚拟机中是一块使用的。监视器监视一块同步代码块,确保一次只有一个线程执行同步代码块。每一个监视器都和一个对象引用相关联。线程在获取锁之前不允许执行同步代码。

它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器会将这些线程存储在不同的容器中。

Contention List:竞争队列,所有请求锁的线程首先被放在这个竞争队列中;

Entry List:Contention List中那些有资格成为候选资源的线程被移动到Entry List中;

Wait Set:哪些调用wait方法被阻塞的线程被放置在这里;

OnDeck:任意时刻,最多只有一个线程正在竞争锁资源,该线程被成为OnDeck;

Owner:当前已经获取到所资源的线程被称为Owner;

!Owner:当前释放锁的线程。
在这里插入图片描述

什么是Monitor?

我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。

与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。

Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

monitor的内部结构

  • _owner:指向持有ObjectMonitor对象的线程
  • _WaitSet:存放处于wait状态的线程队列
  • _EntryList:存放处于等待锁block状态的线程队列
  • _recursions:锁的重入次数
  • _count:用来记录该线程获取锁的次数
ObjectMonitor() {
    _header       = NULL;
    _count        = 0;		  //计数器
    _waiters      = 0;
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;    // 记录当前持有锁的线程ID
    _WaitSet      = NULL;    // 等待池:处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ;    // 锁池:处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

Monitor与java对象以及线程是如何关联的?

1.如果一个java对象被某个线程锁住,则该java对象的Mark Word字段中ptr_to_heavyweight_monitor等指向monitor的指针,会指向monitor的起始地址
2.Monitor的_owner字段会存放拥有相关联对象锁的线程的唯一标识

可重入锁 不可重入锁

monitor中的计数器即 _count 字段,在monitorenter时计数器加1,monitorexit时计数器减1,如果计数器的值为0的话,就代表着这个对象未被线程使用。

重入锁实现机制就是基于一个锁关联一个线程持有者和计数器,当计数器为0时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的线程持有者,同时将锁计数器置为1;此时其它线程请求该锁,就只能等待,而该锁持有者却可以再次请求锁,同时锁计数器会递增,当线程退出同步代码块时,计数器会递减,直至锁计数器为0,则释放。

不可重入锁,即某线程执行某个方法已经获取了该锁,若在方法中尝试再次获取锁,就会获取不到而被阻塞。

锁池和等待池

每个对象都有两个池,锁池和等待池

锁池:假设线程A已经拥有了某个对象的锁,而其它的线程想要调用这个对象的某个synchronized方法(或者synchronized块),由于这些线程在进入对象的synchronized方法之前必须先获得该对象的锁的拥有权,但是该对象的锁目前正被线程A拥有,所以这些线程就进入了该对象的锁池中。

等待池:假设一个线程A调用了某个对象的wait()方法,线程A就会释放该对象的锁(因为wait()方法必须出现在synchronized中,这样自然在执行wait()方法之前线程A就已经拥有了该对象的锁),同时线程A就进入到了该对象的等待池中。如果另外的一个线程调用了相同对象的notifyAll()方法,那么处于该对象的等待池中的线程就会全部进入该对象的锁池中,准备争夺锁的拥有权。如果另外的一个线程调用了相同对象的notify()方法,那么仅仅有一个处于该对象的等待池中的线程(随机)会进入该对象的锁池。

对应着线程状态的Blocked状态的同步阻塞和等待阻塞。

monitorenter monitorexit

public class SynchronizedDemo {
    public void method() {
        synchronized (this) {
            System.out.println("Method 1 start");
        }
    }
}

https://images2018.cnblogs.com/blog/679616/201803/679616-20180313162541533-2019188199.png
monitorenter :每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit:执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

public class SynchronizedMethod {
    public synchronized void method() {
        System.out.println("Hello World!");
    }
}

在这里插入图片描述
从反编译的结果来看,方法的同步并没有通过指令monitorenter和monitorexit来完成(理论上其实也可以通过这两条指令来实现),不过相对于普通方法,其常量池中多了ACC_SYNCHRONIZED标示符。JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。 其实本质上没有区别,只是方法的同步是一种隐式的方式来实现,无需通过字节码来完成。

锁的状态

synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的。但是监视器锁本质又是依赖于底层的操作系统的mutex lock来实现的。而操作系统实现线程之间的切换就需要从用户态转换到核心态,这个成本非常高,状态之间的转换需要相对比较长的时间,这就是synchronized效率低的原因。因此,这种依赖于操作系统mutex lock所实现的锁我们称为"重量级锁"。jdk对于synchronized做的种种优化,其核心都是为了减少这种重量级锁的使用。jdk1.6以后,为了减少获得锁和释放锁带来的性能消耗,引入了"轻量级锁"和"偏向锁"

锁的状态总共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁(但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级)。JDK 1.6中默认是开启偏向锁和轻量级锁的,我们也可以通过-XX:-UseBiasedLocking来禁用偏向锁。

锁优化

自旋锁与适应性自旋

互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成, 这些操作给Java虚拟机的并发性能带来了很大的压力。

在许多应用上, 共享数据的锁定状态只会持续很短的一段时间, 为了这段时间去挂起和恢复线程并不值得。 如果物理机器有一个以上的处理器或者处理器核心, 能让两个或以上的线程同时并行执行, 我们就可以让后面请求锁的那个线程“稍等一会”, 但不放弃处理器的执行时间, 看看持有锁的线程是否很快就会释放锁。 为了让线程等待, 我们只须让线程执行一个忙循环(自旋) , 这项技术就是所谓的自旋锁。

自旋等待不能代替阻塞, 且先不说对处理器数量的要求, 自旋等待本身虽然避免了线程切换的开销, 但它是要占用处理器时间的,如果锁被占用的时间很长,这就会带来性能的浪费。 因此自旋等待的时间必须有一定的限度, 如果自旋超过了限定的次数仍然没有成功获得锁, 就应当使用传统的方式去挂起线程。 自旋次数的默认值是十次。

JDK 6中对自旋锁的优化, 引入了自适应的自旋。 自适应意味着自旋的时间不再是固定的了, 而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。 如果在同一个锁对象上, 自旋等待刚刚成功获得过锁, 并且持有锁的线程正在运行中, 那么虚拟机就会认为这次自旋也很有可能再次成功, 进而允许自旋等待持续相对更长的时间, 比如持续100次忙循环。 另一方面, 如果对于某个锁, 自旋很少成功获得过锁, 那在以后要获取这个锁时将有可能直接省略掉自旋过程, 以避免浪费处理器资源。 有了自适应自旋, 随着程序运行时间的增长及性能监控信息的不断完善, 虚拟机对程序锁的状况预测就会越来越精准, 虚拟机就会变得越来越“聪明”了。

锁消除

锁消除的主要判定依据来源于逃逸分析的数据支持。如果判断到一段代码中, 在堆上的所有数据都不会逃逸出去被其他线程访问到, 那就可以把它们当作栈上数据对待, 认为它们是线程私有的, 同步加锁自然就无须再进行。

虽然是否需要锁一般是程序员自己判断的,但有许多同步措施并不是程序员自己加入的, 同步的代码在Java程序中出现的频繁程度也许超过了大部分人的想象。

锁粗化

原则上, 我们在编写代码的时候, 总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步, 这样是为了使得需要同步的操作数量尽可能变少, 即使存在锁竞争, 等待锁的线程也能尽可能快地拿到锁。

但是如果一系列的连续操作都对同一个对象反复加锁和解锁, 甚至加锁操作是出现在循环体之中的, 那即使没有线程竞争, 频繁地进行互斥同步操作也会导致不必要的性能损耗。

如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁, 将会把加锁同步的范围扩展(粗化) 到整个操作序列的外部。

轻量级锁

在代码即将进入同步块的时候, 如果此同步对象没有被锁定(锁标志位为“01”状态) , 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record) 的空间, 用于存储锁对象目前的Mark Word的拷贝
在这里插入图片描述
然后, 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了, 即代表该线程拥有了这个对象的锁, 并且对象Mark Word的锁标志位(Mark Word的最后两个比特) 将转变为“00”, 表示此对象处于轻量级锁定状态。
在这里插入图片描述
如果这个更新操作失败了, 那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧, 如果是, 说明当前线程已经拥有了这个对象的锁, 那直接进入同步块继续执行就可以了, 否则就说明这个锁对象已经被其他线程抢占了。 如果出现两条以上的线程争用同一个锁的情况, 那轻量级锁就不再有效, 必须要膨胀为重量级锁, 锁标志的状态值变为“10”, 此时Mark Word中存储的就是指向重量级锁(互斥量) 的指针, 后面等待锁的线程也必须进入阻塞状态。

重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。

上面描述的是轻量级锁的加锁过程, 它的解锁过程也同样是通过CAS操作来进行的, 如果对象的Mark Word仍然指向线程的锁记录, 那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。 假如能够成功替换, 那整个同步过程就顺利完成了; 如果替换失败, 则说明有其他线程尝试过获取该锁, 就要在释放锁的同时, 唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁, 在整个同步周期内都是不存在竞争的”这一经验法则。 如果没有竞争, 轻量级锁便通过CAS操作成功避免了使用互斥量的开销; 但如果确实存在锁竞争, 除了互斥量的本身开销外, 还额外发生了CAS操作的开销。 因此在有竞争的情况下,轻量级锁反而会比传统的重量级锁更慢。

偏向锁

偏向锁的思想是偏向于让第一个获取锁对象的线程,这个线程在之后获取该锁就不再需要进行同步操作,甚至连 CAS 操作也不再需要。

当锁对象第一次被线程获得的时候,进入偏向状态,标记为 1 01。同时使用 CAS 操作将线程 ID 记录到 Mark Word 中,如果 CAS 操作成功,这个线程以后每次进入这个锁相关的同步块就不需要再进行任何同步操作。

当有另外一个线程去尝试获取这个锁对象时,偏向状态就宣告结束,此时撤销偏向(Revoke Bias)后恢复到未锁定状态或者轻量级锁状态。
在这里插入图片描述

优点缺点适用场景
偏向锁加锁和解锁都不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距如果线程间存在锁竞争,会带来额外的锁撤销的消耗只有一个线程访问同步块
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,使用自旋会消耗CPU追求响应时间,同步块执行速度非常块
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,响应时间慢追求吞吐量,同步块执行时间较长
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值