当前位置 : 主页 > 编程语言 > java >

Java并发JUC——synchronized和Lock

来源:互联网 收集:自由互联 发布时间:2023-02-04
synchronized synchronized作用 原子性:synchronized保证语句块内操作是原子的。 可见性:synchronized保证可见性(通过“在执行unlock之前,必须先把此变量同步回主内存”实现)。 有序性:sy

synchronized

synchronized作用

  • 原子性:synchronized保证语句块内操作是原子的。

  • 可见性:synchronized保证可见性(通过“在执行unlock之前,必须先把此变量同步回主内存”实现)。

  • 有序性:synchronized保证有序性(通过“一个变量在同一时刻只允许一条线程对其进行lock操作”)。

synchronized的三种应用方式

  • 1、修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁。
  • 2、修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁。
  • 3、修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象。

Synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。Synchronized的作用主要有三个:

  • 1、确保线程互斥的访问同步代码
  • 2、保证共享变量的修改能够及时可见
  • 3、有效解决重排序问题

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

synchronize底层原理:

Java 虚拟机中的同步(Synchronization)基于进入和退出Monitor对象实现, 无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步都是如此。在 Java 语言中,同步用的最多的地方可能是被 synchronized 修饰的同步方法。同步方法 并不是由 monitorenter 和 monitorexit 指令来实现同步的,而是由方法调用指令读取运行时常量池中方法表结构的 ACC_SYNCHRONIZED 标志来隐式实现的,关于这点,稍后详细分析。

 

同步代码块:monitorenter指令插入到同步代码块的开始位置,monitorexit指令插入到同步代码块的结束位置,JVM需要保证每一个monitorenter都有一个monitorexit与之相对应。任何对象都有一个monitor与之相关联,当且一个monitor被持有之后,他将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor所有权,即尝试获取对象的锁;

 

在JVM中,对象在内存中的布局分为三块区域:对象头、实例变量和填充数据。如下:

实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

 

填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

 

对象头:Hotspot虚拟机的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。其中Klass Point是是对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,Mark Word用于存储对象自身的运行时数据,它是实现轻量级锁和偏向锁的关键。

 

Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),但是如果对象是数组类型,则需要三个机器码,因为JVM虚拟机可以通过Java对象的元数据信息确定Java对象的大小,但是无法从数组的元数据来确认数组的大小,所以用一块来记录数组长度。

 

Monior:我们可以把它理解为一个同步工具,也可以描述为一种同步机制,它通常被描述为一个对象。与一切皆对象一样,所有的Java对象是天生的Monitor,每一个Java对象都有成为Monitor的潜质,因为在Java的设计中 ,每一个Java对象自打娘胎里出来就带了一把看不见的锁,它叫做内部锁或者Monitor锁。Monitor 是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor关联(对象头的MarkWord中的LockWord指向monitor的起始地址),同时monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。其结构如下: Owner:初始时为NULL表示当前没有任何线程拥有该monitor record,当线程成功拥有该锁后保存线程唯一标识,当锁被释放时又设置为NULL; EntryQ:关联一个系统互斥锁(semaphore),阻塞所有试图锁住monitor record失败的线程。 RcThis:表示blocked或waiting在该monitor record上的所有线程的个数。 Nest:用来实现重入锁的计数。 HashCode:保存从对象头拷贝过来的HashCode值(可能还包含GC age)。 Candidate:用来避免不必要的阻塞或等待线程唤醒,因为每一次只有一个线程能够成功拥有锁,如果每次前一个释放锁的线程唤醒所有正在阻塞或等待的线程,会引起不必要的上下文切换(从阻塞到就绪然后因为竞争锁失败又被阻塞)从而导致性能严重下降。Candidate只有两种可能的值0表示没有需要唤醒的线程1表示要唤醒一个继任线程来竞争锁。

 

初始是无锁状态,其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构。

在运行期间MarkWord里存储的数据会随着锁状态的变化而变化。

Java虚拟机对synchronize的优化 锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段。

 

偏向锁 偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

偏向锁的获得和撤销流程

偏向锁

作用:减少同一线程获取锁的代价 引入偏向锁是因为大多数情况下,锁并不存在多线程竞争,总是由同一线程多次获得。

 

获取锁

  • 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)或者轻量级锁的状态;

 

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

轻量级锁及膨胀流程图

获取锁:

  • 1、判断当前对象是否处于无锁状态(hashcode、0、01),若是,则JVM首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方把这份拷贝加了一个Displaced前缀,即Displaced Mark Word);否则执行步骤(3);
  • 2、JVM利用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指正,如果成功表示竞争到锁,则将锁标志位变成00(表示此对象处于轻量级锁状态),执行同步操作;如果失败则执行步骤(3);
  • 3、判断当前对象的Mark Word是否指向当前线程的栈帧,如果是则表示当前线程已经持有当前对象的锁,则直接执行同步代码块;否则只能说明该锁对象已经被其他线程抢占了,这时轻量级锁需要膨胀为重量级锁,锁标志位变成10,后面等待的线程将会进入阻塞状态;

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

  • 1、取出在获取轻量级锁保存在Displaced Mark Word中的数据;
  • 2、用CAS操作将取出的数据替换当前对象的Mark Word中,如果成功,则说明释放锁成功,否则执行(3);
  • 3、如果CAS操作替换失败,说明有其他线程尝试获取该锁,则需要在释放锁的同时需要唤醒被挂起的线程。

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

自旋锁与自适应自旋锁

  • 引入自旋锁的原因:互斥同步对性能最大的影响是阻塞的实现,因为挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来很大的压力。同时虚拟机的开发团队也注意到在许多应用上面,共享数据的锁定状态只会持续很短一段时间,为了这一段很短的时间频繁地阻塞和唤醒线程是非常不值得的。

  • 自旋锁:让该线程执行一段无意义的忙循环(自旋)等待一段时间,不会被立即挂起(自旋不放弃处理器额执行时间),看持有锁的线程是否会很快释放锁。自旋锁在JDK 1.4.2中引入,默认关闭,但是可以使用-XX:+UseSpinning开开启;在JDK1.6中默认开启。

  • 自旋锁的缺点:自旋等待不能替代阻塞,虽然它可以避免线程切换带来的开销,但是它占用了处理器的时间。如果持有锁的线程很快就释放了锁,那么自旋的效率就非常好;反之,自旋的线程就会白白消耗掉处理器的资源,它不会做任何有意义的工作,这样反而会带来性能上的浪费。所以说,自旋等待的时间(自旋的次数)必须要有一个限度,例如让其循环10次,如果自旋超过了定义的时间仍然没有获取到锁,则应该被挂起(进入阻塞状态)。通过参数-XX:PreBlockSpin可以调整自旋次数,默认的自旋次数为10。

  • 自适应的自旋锁:JDK1.6引入自适应的自旋锁,自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定:如果在同一个锁的对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。简单来说,就是线程如果自旋成功了,则下次自旋的次数会更多,如果自旋失败了,则自旋的次数就会减少。

  • 自旋锁使用场景:从轻量级锁获取的流程中我们知道,当线程在获取轻量级锁的过程中执行CAS操作失败时,是要通过自旋来获取重量级锁的。(见前面“轻量级锁”)

重量级锁

重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。

 

当锁升级为轻量级锁之后,如果依然有新线程过来竞争锁,首先新线程会自旋尝试获取锁,尝试到一定次数(默认10次)依然没有拿到,锁就会升级成重量级锁。

synchronized结构:

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

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

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

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

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

大量并发线程会在contention List中,然后将有资格成为候选的放到entry list中。调用的wait的线程放到wait set中,当被唤醒后会放到entry list中。

 

指定EntryList中的某个线程为OnDeck线程(一般是最先进去的那个线程),然后onedeck线程去竞争锁,但是此时其他未进入contention list的线程会先自旋一下看是否能获得到锁,所以说synchronied不是公平的。

 

JVM 每次从Waiting Queue 的尾部取出一个线程放到OnDeck作为候选者,但是如果并发比较高,Waiting Queue会被大量线程执行CAS操作,为了降低对尾部元素的竞争,将Waiting Queue 拆分成ContentionList 和 EntryList 二个队列, JVM将一部分线程移到EntryList 作为准备进OnDeck的预备线程。另外说明几点:

  • 1、所有请求锁的线程首先被放在ContentionList这个竞争队列中;

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

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

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

  • 5、处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的);

  • 6、作为Owner 的A 线程执行过程中,可能调用wait 释放锁,这个时候A线程进入 Wait Set , 等待被唤醒。

这是 synchronized 在 JDK 6之前的实现原理。

锁消除 消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

 

synchronized的可重入性: 从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

Lock

  • 锁是一种工具,用于控制对共享资源的访问
  • Lock和synchronized,这两个是最常见的锁,它们都可以达到线程安全的目的,但是在使用上和功能上又有较大的不同
  • Lock并不是用来替代synchronized的,而是当使用synchronized不合适或不足以满足要求的时候,来提供高级功能
  • Lock接口中最常见的实现类是ReentrantLock
  • 通常情况下,Lock只允许一个线程来访问这个共享资源,不过有的时候,一些特殊的实现也可允许并发访问,比如ReadWriteLock里面的ReadLock

synchronize和Lock的区别: 两者都是锁,用来控制并发冲突,区别在于Lock是个接口,提供的功能更加丰富,除了这个外,他们还有如下区别:

  • 1、synchronize自动释放锁,而Lock必须手动释放,并且代码中出现异常会导致unlock代码不执行,所以Lock一般在Finally中释放,而synchronize释放锁是由JVM自动执行的。
  • 2、Lock有共享锁的概念,所以可以设置读写锁提高效率,synchronize不能。(两者都可重入)
  • 3、Lock可以让线程在获取锁的过程中响应中断,而synchronize不会,线程会一直等待下去。lock.lockInterruptibly()方法会优先响应中断,而不是像lock一样优先去获取锁。
  • 4、Lock锁的是代码块,synchronize还能锁方法和类。
  • 5、Lock可以知道线程有没有拿到锁,而synchronize不能

为什么需要Lock

为什么synchronized不够用

  • 效率低:锁的释放情况少,试图获得锁时不能设定超时,不能中断一个正在试图获得锁的线程
  • 不够灵活(读写锁更灵活):加锁和释放锁的时机单一,每个锁仅有单一的条件(某个对象),可能是不够的
  • 无法知道是否成功获取到锁

Lock的主要方法介绍

  • Lock中声明了4个方法来获取锁
  • lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()

lock()

  • lock()就是最普通的获取锁,如果锁已被其他线程获取,则进行等待
  • lock()不会像synchronized一样在异常的时候自动释放锁
  • 因此最佳实践是,在finally中释放锁,以保证发生异常时锁一定被释放
  • lock()方法不能被中断,这会带来很大隐患:一旦陷入死锁,lock()就会陷入永久等待

tryLock()

  • tryLock()用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功返回true;否则返回false,代表获取锁失败
  • 相比于lock(),这样的方法显然功能更强大了,我们可以根据是否能获取到锁来决定后续程序的行为
  • 该方法会立刻返回,即便在拿不到锁时也不会一直在那等待

tryLock(long time, TimeUnit unit)

  • tryLock(long time, TimeUnit unit):超时就放弃

lockInterruptibly()

  • lockInterruptibly():相当于tryLock(long time, TimeUnit unit)把超时时间设置为无限,在等待锁的过程中,线程可以被中断

unlock()

  • unlock():解锁

可见性的保证

可见性 什么是可见性?如果一个线程对于另外一个线程是可见的,那么这个线程的修改就能够被另一个线程立即感知到。

Java锁保证可见性的具体实现 Happens-before规则 从JDK 5开始,JSR-133定义了新的内存模型,内存模型描述了多线程代码中的哪些行为是合法的,以及线程间如何通过内存进行交互。

 

新的内存模型语义在内存操作(读取字段,写入字段,加锁,解锁)和其他线程操作上创建了一些偏序规则,这些规则又叫作Happens-before规则。它的含义是当一个动作happens before另一个动作,这意味着第一个动作被保证在第二个动作之前被执行并且结果对其可见。我们利用Happens-before规则来解释Java锁到底如何保证了可见性。

 

Java内存模型一共定义了八条Happens-before规则,和Java锁相关的有以下两条:

  • 内置锁的释放锁操作发生在该锁随后的加锁操作之前
  • 一个volatile变量的写操作发生在这个volatile变量随后的读操作之前

 

Lock的加解锁和synchronized有同样的内存语义,也就是说,下一个线程加锁后可以看到所有前一个线程解锁前发生的操作

锁的分类

  • 这些分类是从各种不同角度出发去看的
  • 这些分类并不是互斥的,也就是说多个类型可以并存,有可能一种锁同时属于2种类型
  • 比如ReentrantLock既是互斥锁,又是可重入锁

为什么会诞生非互斥同步锁(乐观锁)

  • 互斥同步锁(悲观锁)的劣势
    • 阻塞和唤醒带来的性能劣势
    • 永久阻塞:如果持有锁的线程被永久阻塞,比如遇到了无限循环、死锁等活跃性问题,那么等待该线程释放锁的那几个悲催线程,将永远也得不到执行
    • 优先级反转

 

悲观锁

  • 如果不锁住这个资源,别人就会来争抢,就会造成数据结果的错误,所以每次悲观锁为了确保结果的正确性,会在每次获取并修改数据时,把数据锁住,让别人无法访问该数据,这样就可以确保数据万无一失了
  • Java中悲观锁的实现就是synchronized和Lock相关的类

 

乐观锁

  • 认为自己在处理操作的时候不会有其他线程阿里干扰,所以并不会锁住被操作对象
  • 在更新的时候,去对比在我修改的期间数据有没有被其他人改变过,如果没有被改变过,就说明真的是只有我自己在操作,那我就正常去修改数据;如果数据和我一开始拿到的不一样了,说明其他人在这段时间内修改过数据,那么我就不能继续刚才的更新数据过程了,我会选择放弃、报错、重试等
  • 乐观锁的实现一般都是利用CAS算法来实现的

 

典型例子

  • 悲观锁:synchronized和Lock相关的类
  • 乐观锁的典型例子就是原子类、并发容器等
  • Git:Git就是乐观锁的典型例子,当我们往远程仓库push的时候,git会检查远程仓库的版本是不是领先于我们现在的版本,如果远程仓库的版本号和本地的不一样,就表示有其他人修改了远端代码,那么我们这次提交就失败,如果远端和本地的版本号一致,我们就可以顺利提交版本到远端仓库
  • 数据库:select for update就是悲观锁;使用version控制数据库的数据,那么就是乐观锁

 

悲观锁和乐观锁开销对比

  • 悲观锁的开始开销要高于乐观锁,但是特点是一劳永逸,临界区持锁时间就算越来越差,也不会对互斥锁的开销造成影响
  • 相反,虽然乐观锁一开始的开销比悲观锁小,但是如果自旋时间很长或者不停重试,那么消耗的资源也会越来越多

 

悲观锁和乐观锁的使用场景

  • 悲观锁:适合并发写入多的情况,适用于临界区持锁时间比较长的情况,悲观锁可以避免大量的无用自旋等消耗,典型情况:
    • 临界区有I/O操作
    • 临界区代码复杂或循环量大
    • 临界区竞争非常激烈
  • 乐观锁:适合并发写入少,大部分是读取的场景,不加锁能让读取性能大幅提高

可重入锁和非可重入锁

源码对比:可重入锁ReentrantLock以及非可重入锁ThreadPoolExecutor的Worker类

ReentrantLock的其他方法介绍

  • isHeldByCurrentThread():锁是否被当前线程持有
  • getQueueLength():返回当前正在等待这把锁的队列有多长,一般这两个方法是开发和调试的时候使用,上线后用到的不多

公平锁和非公平锁

  • 公平指的是按照线程请求的顺序,来分配锁,非公平指的是,不完全按照线程的请求顺序,在一定情况下可以插队
  • 注意:非公平页同样不提倡“插队”行为,这里的非公平,指的是“在合适的时机”插队,而不是盲目插队
  • 如果在创建ReentrantLock对象时,参数填写为true,那么这就是个公平锁
  • 针对tryLock()方法,它是很猛的,它不遵守设定的公平的规则
  • 例如,当有线程执行tryLock()的时候,一旦有线程释放了锁,那么这个正在tryLock()的线程就能获取到锁,即使在它之前已经有其他线程正在等待队列里等待了

共享锁和排它锁

  • 排它锁,又称为独占锁、独享锁
  • 共享锁,又称为读锁,获得共享锁之后,可以查看但无法修改和删除数据,其他线程此时也可以获取到共享锁,也可以查看,但无法修改和删除数据
  • 共享锁和排它锁的典型是读写锁ReentrantReadWriteLock,其中读锁是共享锁,写锁是排它锁

读写锁的作用

  • 在没有读写锁之前,我们设定使用ReentrantLock,那么虽然我们保证了线程安全,但是也浪费了资源:多个读操作同时进行,并没有线程安全问题
  • 在读的地方使用读锁,在写的地方使用写锁,灵活控制,如果没有写锁的情况下,读是无阻塞的,提高了程序的执行效率

读写锁的规则

  • 多个线程只申请读锁,都可以申请到
  • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
  • 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,则申请的线程会一直等待释放写锁
  • 一句话总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现
  • 换一种思路更容易理解:读写锁只是一把锁,可以通过两种方式锁定:读锁定和写锁定。读写锁可以同时被一个或多个线程读锁定,也可以被单一线程写锁定。但是永远不能同时对这把锁进行读锁定和写锁定

ReentrantReadWriteLock使用

public class CinemaReadWrite { private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); public static void read(){ readLock.lock(); try { System.out.println(Thread.currentThread().getName()+"得到了读锁,并正在读取中"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放了读锁"); } } public static void write(){ writeLock.lock(); try { System.out.println(Thread.currentThread().getName()+"得到了写锁,并正在写入中"); Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } finally { writeLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放了写锁"); } } public static void main(String[] args) { new Thread(() -> read(),"Thread-1").start(); new Thread(() -> read(),"Thread-2").start(); new Thread(() -> write(),"Thread-3").start(); new Thread(() -> write(),"Thread-4").start(); } }

读锁插队策略

  • 公平锁:不允许插队
  • 非公平锁
    • 写锁可以随时插队
    • 读锁仅在等待队列头节点不是想获取写锁的线程的时候才可以插队 代码演示:
public class NonfairBargeDemo { //公平锁,读锁无法插队,非公平锁,读锁可以插队 private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true); private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); public static void read(){ System.out.println(Thread.currentThread().getName()+"开始尝试获取读锁"); readLock.lock(); try{ System.out.println(Thread.currentThread().getName()+"得到读锁正在读取"); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放读锁"); } } public static void write(){ System.out.println(Thread.currentThread().getName()+"开始尝试获取写锁"); writeLock.lock(); try{ System.out.println(Thread.currentThread().getName()+"得到写锁正在写入"); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } finally { writeLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放写锁"); } } public static void main(String[] args) { new Thread(() -> write(),"Thread-1").start(); new Thread(() -> read(),"Thread-2").start(); new Thread(() -> read(),"Thread-3").start(); new Thread(() -> write(),"Thread-4").start(); new Thread(() -> read(),"Thread-5").start(); new Thread(new Runnable() { @Override public void run() { Thread[] threads = new Thread[1000]; for (int i = 0; i < 1000; i++) { threads[i] = new Thread(() -> read(),"子线程创建的Thread" + i); } for (int i = 0; i < 1000; i++) { threads[i].start(); } } }).start(); } }

锁的升降级

  • 支持锁的降级,不支持锁的升级
public class Upgrading { private static ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(); private static ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock(); private static ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock(); public static void readUpgrading(){ readLock.lock(); try { System.out.println(Thread.currentThread().getName()+"得到了读锁,并正在读取中"); Thread.sleep(1000); System.out.println("升级会带来阻塞"); writeLock.lock(); System.out.println(Thread.currentThread().getName()+"获取到了写锁,升级成功"); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放了读锁"); } } public static void writeDowngrading(){ writeLock.lock(); try { System.out.println(Thread.currentThread().getName()+"得到了写锁,并正在写入中"); Thread.sleep(1000); readLock.lock(); System.out.println("在不释放写锁的情况下,直接获取读锁"); } catch (InterruptedException e) { e.printStackTrace(); } finally { readLock.unlock(); writeLock.unlock(); System.out.println(Thread.currentThread().getName()+"释放了写锁"); } } public static void main(String[] args) throws InterruptedException { System.out.println("先演示降级是可以的"); Thread thread1 = new Thread(() -> writeDowngrading(), "Thread-1"); thread1.start(); System.out.println("----------------------------------"); thread1.join(); System.out.println("先演示升级是不可以的"); new Thread(() -> readUpgrading(),"Thread-2").start(); } }

共享锁和排它锁总结

  • 1、ReentrantReadWriteLock实现了ReadWriteLock,最主要的有两个方法:readLock()和writeLock()用来获取读锁和写锁
  • 2、所得申请和释放的策略
    • 多个线程只申请读锁,都可以申请到
    • 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁
    • 如果有一个线程已经占用的写锁,则此时其他线程如果申请写锁或读锁,则申请的线程会一直等待释放写锁
  • 3、插队策略:为了防止饥饿,读锁不能插队
  • 4、升降级策略:只能降级,不能升级
  • 5、适用场合:相比于ReentrantLock适用于一般场合,ReentrantReadWriteLock适用于读多写少的情况,合理使用可以进一步提高并发策略

自旋锁和阻塞锁

自旋锁 自旋锁是采用让当前线程不停地的在循环体内执行实现的,当循环的条件被其他线程改变时 才能进入临界区 由于自旋锁只是将当前线程不停地执行循环体,不进行线程状态的改变,所以响应速度更快。但当线程数不停增加时,性能下降明显,因为每个线程都需要执行,占用CPU时间。如果线程竞争不激烈,并且保持锁的时间段,适合使用自旋锁。

 

在Java1.5版本及以上的并发框架java.util.concurrent的atmoic包下的类基本都是自旋锁的实现 AtomicInteger的实现:自旋锁的实现原理是CAS,AtomicInteger中调用unsafe进行自增操作的源码中的do-while循环就是一个自旋操作,如果修改过程中遇到其他线程竞争导致没有修改成功,就在while里死循环,直至修改成功

 

自旋锁适用场景

  • 自旋锁一般用于多核服务器,在并发度不是特别高的情况下,比阻塞锁的效率高
  • 自旋锁适用于临界区比较短小的情况,否则如果临界区很大(线程一旦拿到锁,很久以后才会释放),那也是不合适的

 

阻塞锁 阻塞锁,与自旋锁不同,改变了线程的运行状态。 在JAVA环境中,线程Thread有如下几个状态:

  • 1、新建状态
  • 2、就绪状态
  • 3、运行状态
  • 4、阻塞状态
  • 5、死亡状态

阻塞锁,可以说是让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间) 时,才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。 JAVA中,能够进入\退出、阻塞状态或包含阻塞锁的方法有 ,synchronized 关键字(其中的重量锁),ReentrantLock,Object.wait()\notify(),LockSupport.park()/unpart()(j.u.c经常使用)

 

阻塞锁的优势在于,阻塞的线程不会占用cpu时间, 不会导致 CPu占用率过高,但进入时间以及恢复时间都要比自旋锁略慢。

 

在竞争激烈的情况下 阻塞锁的性能要明显高于 自旋锁。

 

理想的情况则是; 在线程竞争不激烈的情况下,使用自旋锁,竞争激烈的情况下使用,阻塞锁。

可中断锁

  • 在Java中,synchronized就不是可中断锁,而Lock是可中断锁,因为tryLock(time)和lockInterruptibly都能响应中断
  • 如果某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,可能由于等待时间过长,线程B不想等了,想先处理其他事情,那么可以中断它,这就是可中断锁

在写代码时如何优化锁和提高并发性能

  • 缩小同步代码块
  • 尽量不要锁住方法
  • 减少锁的次数
  • 避免人为制造“热点”
  • 锁中尽量不要包含锁
  • 选择合适的锁类型或合适的工具类

参考: https://www.cnblogs.com/mingyao123/p/7424911.html

https://www.cnblogs.com/sunny-miss/p/11794156.html

https://blog.csdn.net/huxuhang/article/details/92838075

http://ifeve.com/java_lock_see1/

https://ifeve.com/java_lock_see3/

https://www.cnblogs.com/aspirant/p/11470858.html

https://zhuanlan.zhihu.com/p/29866981

https://www.cnblogs.com/woshimrf/p/java-synchronized.html

上一篇:Java并发JUC——Atomic原子类
下一篇:没有了
网友评论