• 0

  • 515

Java语言中锁机制

机器猫

机器学习

1星期前

前言

最近正在重读《深入理解java虚拟机》第三版,故记录一篇关于锁相关的内容,一来分享给没有看书的小伙伴,二来当做加深记忆吧。

多任务并发已经是计算机中不可缺少的一部分了,很多时候让计算机同一时刻做几件事,不仅仅是因为运算能力提高了,还有一个重要原因就是cpu的运算速度和存储、通信子系统速度差异太大。所以不得不“压榨”运算能力。 而压榨运算能力就不得不用到多线程,在单线程的情况下运算环境较为简单,而到了多线程情况下则会产生很多意想不到的结果。要保证在多线程的情况下各个线程“相安无事”的运行,则必须用到锁机制。

java内存模型

在讲锁机制之前,我们必须先了解jvm的内存模型。 模型图如下

java线程读写数据是直接读写工作内存中的数据,工作内存可能并非是物理内存,为了性能很大可能是高速缓存和寄存器。线程间通信必须通过内存来实现。假设初始值x = 0, 然后线程A将x值修改为1, 那么B要知道此时A修改过后的值就必须是A线程先将值从工作内存中写入到主内存,然后线程B再从主内存中读取x的值,如此完成线程间的通信--共享内存的方式。

JVM内存模型围绕三个点进行: 原子性、可见性、有序性

原子性:不可分割,java中的基本类型的读写基本可以认为是原子操作,如果要在更大范围内保证原子性,可以是用synchronized来保证原子性。

可见性:当一个线程修改共享变量的值,其他线程能够立即得知这个变化。volatilesynchronizedfinal都能够保证内存可见性。volatile能够保证一个被修改之后立即同步到主内存中,然后其他线程使用到被volatile修饰的变量时,必须先从主内存中同步到工作内存中。synchronized保证可见性是一个线程unlock时,必须将修改的值同步到主内存中,下一个获取到lock的线程必须先同步主内存数据,从而保证可见性。

有序性:如果在本线程内观察,所有操作都是有序的;如果在另外一个线程中观察另一个线程,所有操作都是无序的。 前面半句意思是在同一个线程内表现为串行的语义,后半句是“指令重排序”现象和“工作内存与主内存之间延迟”现象。

先行发生:指的是如果A先行发生于B,那么操作A的影响能够被操作B观察到,“影响”包括修改了共享内存变量的值、发送的消息、调用方法等。由于优化重排序的原因,操作之间的时间上先后执行和“先行发生”并没有关系。

volatile

需要注意的是volatile并不是锁,它是jvm提供的最轻量级的同步机制。大多数人对volatile的原理和使用并不清楚。volatile主要具备两个特性:

  • 保证内存可见性
  • 禁止指令重排序

内存可见性

volatile修饰的变量,被线程修改之后会立即从工作内存中同步到主内存中,线程若要读被volatile修饰的变量则必须先从主内存中同步最新的值,这样就保证了线程之间变量的同步。但是线程之间变量的值并非立即同步的,各个线程之间的值可能存在不一致的情况,但是由于每一次读的时候都从主内存中更新,因此我们可以认为不会出现不一致的情况。

但是由于volatile只能保证可见性,并不保证原子性,所以在不符合下面的两种情况还是需要通过加锁来保证原子性。

  • 运算结果并不依赖当前值 例如: a ++ 、 b += 3
  • 变量不需要参加其他状态变量共同参与不变约束。 例如: low < up

禁止指令重排序

现代cpu为了提高执行效率,一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段。然后,多条指令可以同时存在于流水线中,同时被执行。但是无论怎样重排序必须要满足as-if-serial语义,这也是为什么我们在单线程的情况下编程并不需要考虑代码执行顺序的问题,因为无论怎样排序,最后执行的结果总是和顺序执行的结果一致。

禁止重排序时候通过内存屏障来实现的,意味着重排序时不能将后面的指令重排序到内存屏障之前的位置。若只有一个cpu访问内存时并不需要内存屏障,若多个cpu访问内存时,并且一个正在观察另外一个时,就需要内存屏障来保证一致性。

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • 1、它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  • 2、它会强制将对缓存的修改操作立即写入主存;

  • 3、如果是写操作,它会导致其他CPU中对应的缓存行无效。

    由于volatile不能保证原子性,所以当且仅当满足下边所有条件时,才可使用。

    • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
    • 改变量不会与其他状态变量一起纳入不变性条件中。
    • 在访问变量时不需要加锁。

synchronized

synchronized是我们最常使用的加锁方式,它不仅能提供互斥,还能保证可见性。synchronized最常用的互斥手段,synchronized经过编译之后,会在同步块前后分别形成monitorentermonitorexit两个字节码指令。这两个指令都需要明确指定一个引用类型的对象参数来确定需要锁定和解锁的对象。

根据虚拟机的规范要求,在执行monitorenter指令时,首先要尝试获取对象的锁,如果对象没有被锁定,或者当前线程已经拥有那个对象的锁,把锁的计数器加1,相应的,如果执行monitorexit指令就会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,那么当前线程就要一直阻塞等待,直到对象锁被另外一个线程释放为止。同时Synchronized锁是可重入的,假设对象person有两个被Synchronized修饰的同步方法a、b,当线程执行完a之后还可以接着执行b,所以这在一定程度上避免了死锁,如下代码所示:

public class Person {
    public synchronized void a(){
        System.out.println("enter a method");
        b();
        System.out.println("exit a method");
    }
    public synchronized void b(){
        System.out.println("enter b method");
    }
    public static void main(String[] args) {
        new SyncLockTest().a();
    }
}
复制代码

synchronized如何保证可见性?

当一个线程释放锁时,将自己修改的数据从工作内存更新到主内存中,获取锁那个线程也会先将主内存中的数据同步到主机的工作内存中,如此就保证了内存可见性

ReentrantLock

ReentrantLock 类实现了Lock,它拥有与 synchronized相同的并发性和内存语义,但是添加了类似公平锁、轮询锁、定时锁等候和可中断锁等候的一些特性。此外,它还提供了在激烈争用情况下更佳的性能。

但相比synchronized锁来说,Lock锁使用需要手动获取、手动释放。我们在创建Lock实例的时候可以指定使用公平锁还是非公平锁,非公平锁性能会更好一下(公平锁需要维护公平所需要的记账和同步),所以我们通常时候时都是用非公平锁。

 public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }
复制代码

这里FairSyncNonfairSync都继承了Sync。而公平锁和非公平锁的区别在于获取锁的方式。 非公平锁:

final void lock() {
    // 先判断此时锁是否有其他线程持有,没有的话通过CAS更新锁状态
   if (compareAndSetState(0, 1)) 
      setExclusiveOwnerThread(Thread.currentThread());
   else
      acquire(1);
}
复制代码

自旋锁

Java的线程是映射到操作系统的内核线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转换到内核态中,因此状态转换需要耗费很多的处理器时间,对于代码简单的同步块状态转换消耗的时间有可能比用户代码执行的时间还要长。

那么就可以采用自旋锁的方式,假设这个时候有A线程持有锁,B线程等待锁,若是A线程持有锁的时间并不长,那么这个时候B线程不会被阻塞而是采用“忙等”的方式等待锁被释放。从而避免了操作系统切换用户态、内核态的消耗。

自旋锁带来的问题:

1、可能占用太多cpu资源:

  • 若是A线程在短时间内并不释放锁,那么这种方式可能占用太多Cpu时间,导致Cpu资源浪费。因此JVM有一个默认自旋次数限制(默认是10次,可以使用-XX:PreBlockSpin来更改),若还是没有获取到锁,那就应该使用传统的方式去挂起线程了。

2、死锁问题

  • 假设有一个线程运行在一个处理器上, 而另外一个线程想获取这个处理器的锁, 那么他将一直持有这个Cpu进行自旋操作,你的线程代码则永远无法获得机会释放锁,于是陷入死锁。

自适应自旋

在jdk1.6中引入了自适应的自旋锁。自适应意味着自旋的时间不在固定了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来实现。如果在同一个锁对象刚刚成功获取到了锁,并且持有锁的线程正在运行中,那么JVM久认为这次也能够获取成功,那么久可能多尝试几次来获取,比方说50次。若是对于某一个锁,自适应锁很少成功过,那么jvm可能就忽略掉自旋的过程而直接挂起。

锁消除

锁消除是Java虚拟机在JIT编译是,通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

在动态编译同步块的时候,JIT编译器可以借助一种被称为逃逸分析(Escape Analysis)的技术来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果同步块所使用的锁对象通过这种分析被证实只能够被一个线程访问,那么JIT编译器在编译这个同步块的时候并不生成synchronized所表示的锁的申请与释放对应的机器码,而仅生成原临界区代码对应的机器码,这就造成了被动态编译的字节码就像是不包含monitorenter(申请锁)和monitorexit(释放锁)这两个字节码指令一样,即消除了锁的使用。这种编译器优化就被称为锁消除(Lock Elision),它使得特定情况下我们可以完全消除锁的开销。

示意图:

锁粗化

锁粗化(Lock Coarsening/Lock Merging)是JIT编译器对内部锁的具体实现所做的一种优化。

一般来说,我们加锁会尽量锁住更小的代码块, 这样使得需要同步的操作数量尽可能小,如果存在竞争那么也能尽快拿到锁。在通常情况下这都是正确的,但是如果反复对同一个对象加锁,甚至加锁操作出现在了循环体中,频繁的进行互斥操作也会导致不必要的性能损耗。 如果虚拟机探测到有这样的情况的话,会把加锁同步的范围扩展到整个操作序列的外部,这样的一个锁范围扩展的操作就称之为锁粗化。

偏向锁

它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS都不做。

偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要同步。大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。

当锁对象第一次被线程获取的时候,线程使用CAS操作把这个锁的线程ID记录再对象Mark Word之中,同时置偏向标志位1。以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。

总结

首先我们先探讨了java内存模型,在此基础之上分析了JVM内存模型围绕三个点: 原子性、可见性、有序性。然后探讨了volatile,它的两个特征:保证内存可见性、禁止指令重排序,以及它的原理和试用的场景。

接着我们探讨了synchronized,它是我们最常使用的加锁方式,它不仅能提供互斥,还能保证可见性 。简单地介绍了它为了实现这些特性背后的实现原理最后介绍了ReentrantLock、自旋锁、自适应自旋、锁消除、锁粗化、偏向锁。

参考:

免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

机器学习

515

相关文章推荐

未登录头像

暂无评论