前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >2024年java面试准备--多线程篇(2)

2024年java面试准备--多线程篇(2)

作者头像
终有救赎
发布2023-10-16 10:15:57
1970
发布2023-10-16 10:15:57
举报
文章被收录于专栏:多线程

本篇文章为上部分续集,本篇主要写了线程安全包括:死锁、乐观锁、各类锁关键字和内存模型:volatile关键字、AQS思想和happens-before等,后续还会详细更新各个关键字,感兴趣的jym可以关注收藏一下,明天更新面试注意的一些多线程问题~ 第一部分链接:2024年java面试准备--多线程篇(1)

线程安全

1、产生死锁的原因

1.因为系统资源不足

2.进程运行推进的顺序不合适

3.资源分配不当

如果系统资源充足,进程的资源请求都能够得到满足,死锁出现的可能性就很低,否则就会因争夺有限的资源而陷入死锁。其次,进程运行推进顺序与速度不同,也可能会产生死锁。

2、产生死锁的四个必要条件

1.互斥条件:一个资源每次只能被一个进程使用

2.请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。

3.不可剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。

4.循环等待条件:若干进程之间行成一种头尾相接的循环等待资源关系。

解决方法: 破坏死锁的任意一条件

乐观锁,破坏资源互斥条件,CAS

资源一次性分配,从而剥夺请求和保持条件、tryLock

可剥夺资源:即当进程新的资源未得到满足时,释放已占有的资源,从而破坏不可剥夺的条件,数据库deadlock超时

资源有序分配法:系统给每类资源赋予一个序号,每个进程按编号递增的请求资源,从而破坏环路等待的条件,转账场景

如何避免java死锁

上面是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破第4个条件不出现循环等待锁的关系。

在开发过程中:

  1. 要注意加锁顺序,保证每个线程按同样的顺序进行加锁
  2. 要注意加锁时限,可以针对所设置一个超时时间
  3. 要注意死锁检查,这是一种预防机制,确保在第一时间发现死锁并进行解决
3、乐观锁,CAS思想

java乐观锁机制:

乐观锁体现的是悲观锁的反面。它是一种积极的思想,它总是认为数据是不会被修改的,所以是不会对数据上锁的。但是乐观锁在更新的时候会去判断数据是否被更新过。乐观锁的实现方案一般有两种(版本号机制和CAS)。乐观锁适用于读多写少的场景,这样可以提高系统的并发量。在Java中 java.util.concurrent.atomic下的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

  乐观锁,大多是基于数据版本 (Version)记录机制实现。即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个 “version” 字段来 实现。 读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。

CAS思想:

CAS就是compare and swap(比较交换),是一种很出名的无锁的算法,就是可以不使用锁机制实现线程间的同步。使用CAS线程是不会被阻塞的,所以又称为非阻塞同步。CAS算法涉及到三个操作:

需要读写内存值V;进行比较的值A;准备写入的值B

当且仅当V的值等于A的值等于V的值的时候,才用B的值去更新V的值,否则不会执行任何操作(比较和替换是一个原子操作-A和V比较,V和B替换),一般情况下是一个自旋操作,即不断重试

缺点:

高并发的情况下,很容易发生并发冲突,如果CAS一直失败,那么就会一直重试,浪费CPU资源

原子性:

功能限制CAS是能保证单个变量的操作是原子性的,在Java中要配合使用volatile关键字来保证线程的安全;当涉及到多个变量的时候CAS无能为力;除此之外CAS实现需要硬件层面的支持,在Java的普通用户中无法直接使用,只能借助atomic包下的原子类实现,灵活性受到了限制

4、悲观锁

当我们要对数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发的发生。

为什么叫做悲观锁呢?因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。

利用悲观锁的解决思路是,我们认为数据修改产生冲突的概率比较大,所以在更新之前,我们显示的对要修改的记录进行加锁,直到自己修改完再释放锁。加锁期间只有自己可以进行读写,其他事务只能读不能写。

5、synchronized底层实现

使用方法: 主要的三种使⽤⽅式

修饰实例⽅法: 作⽤于当前对象实例加锁,进⼊同步代码前要获得当前对象实例的锁

修饰静态⽅法: 也就是给当前类加锁,会作⽤于类的所有对象实例,因为静态成员不属于任何⼀个实例对象,是类成员。

修饰代码块: 指定加锁对象,对给定对象加锁,进⼊同步代码库前要获得给定对象的锁。

总结: synchronized锁住的资源只有两类:一个是对象,一个是

底层实现:

对象头是我们需要关注的重点,它是synchronized实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要结构是由Mark Word 组成,其中**Mark Word**存储对象的hashCode、锁信息或分代年龄或GC标志等信息

锁也分不同状态,JDK6之前只有两个状态:无锁、有锁(重量级锁),而在JDK6之后对synchronized进行了优化,新增了两种状态,总共就是四个状态:无锁状态、偏向锁、轻量级锁、重量级锁,其中无锁就是一种状态了。锁的类型和状态在对象头Mark Word中都有记录,在申请锁、锁升级等过程中JVM都需要读取对象的Mark Word数据。

同步代码块是利用 monitorenter 和 monitorexit 指令实现的,而同步方法则是利用 flags 实现的。

锁的四种状态:

无锁:MarkWord标志位01,没有线程执行同步方法/代码块时的状态。

偏向锁:MarkWord标志位01(和无锁标志位一样)。当没有竞争出现时,默认会使用偏斜锁。JVM 会利用 CAS 操作(compare and swap),在对象头上的 Mark Word 部分设置线程 ID,以表示这个对象偏向于当前线程。当存在锁竞争情况,就需要升级为轻量级锁。

轻量级锁:MarkWord标志位00。轻量级锁是采用自旋锁的方式来实现的,没有抢到锁的线程将自旋,自旋到一定次数(固定次数/自适应)就会升级为重量级锁。轻量级锁是针对竞争锁对象线程不多且线程持有锁时间不长的场景。

重量级锁:通过对象内部监视器(monitor)实现,monitor本质前面也提到了是基于操作系统互斥(mutex)实现的,操作系统实现线程之间切换需要从用户态到内核态切换,成本非常高。

多线程中 synchronized 锁升级的原理是什么?

在锁对象的对象头里面有一个 threadid 字段,在第一次访问的时候 threadid 为空,jvm 让其持有偏向锁,并将 threadid 设置为其线程 id,再次进入的时候会先判断 threadid 是否与其线程 id 一致,如果一致则可以直接使用此对象,如果不一致,则升级偏向锁为轻量级锁,通过自旋循环一定次数来获取锁,执行一定次数之后,如果还没有正常获取到要使用的对象,此时就会把锁从轻量级升级为重量级锁,此过程就构成了 synchronized 锁的升级。

java线程锁机制是怎样的?

java的锁机制就是根据资源竞争的激烈程度不断进行锁升级的过程

Lock原理:

  • Lock的存储结构:一个int类型状态值(用于锁的状态变更),一个双向链表(用于存储等待中的线程)
  • Lock获取锁的过程:本质上是通过CAS来获取状态值修改,如果当场没获取到,会将该线程放在线程等待链表中。
  • Lock释放锁的过程:修改状态值,调整等待链表。
  • Lock大量使用CAS+自旋。因此根据CAS特性,lock建议使用在低锁冲突的情况下。

Lock与synchronized的区别:

  1. Lock的加锁和解锁都是由java代码配合native方法(调用操作系统的相关方法)实现的,而synchronize的加锁和解锁的过程是由JVM管理的
  2. 当一个线程使用synchronize获取锁时,若锁被其他线程占用着,那么当前只能被阻塞,直到成功获取锁。而Lock则提供超时锁和可中断等更加灵活的方式,在未能获取锁的 条件下提供一种退出的机制。
  3. 一个锁内部可以有多个Condition实例,即有多路条件队列,而synchronize只有一路条件队列;同样Condition也提供灵活的阻塞方式,在未获得通知之前可以通过中断线程以 及设置等待时限等方式退出条件队列。
  4. synchronize对线程的同步仅提供独占模式,而Lock即可以提供独占模式,也可以提供共享模式

synchronized

Lock

关键字

自动加锁和释放锁

需要手动调用unlock方法释放锁

jvm层面的锁

API层面的锁

非公平锁

可以选择公平或者非公平锁

锁是一个对象,并且锁的信息保存在了对象中

代码中通过int类型的state标识

有一个锁升级的过程

6、ReenTrantLock底层实现

由于ReentrantLock是java.util.concurrent包下提供的一套互斥锁,相比Synchronized,ReentrantLock类提供了一些高级功能

使用方法:

基于API层面的互斥锁,需要lock()和unlock()方法配合try/finally语句块来完成

底层实现:

ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。它的性能比较好也是因为避免了使线程进入内核态的阻塞状态。想尽办法避免线程进入内核的阻塞状态是我们去分析和理解锁设计的关键钥匙。

和synchronized区别:

1、底层实现:synchronized 是JVM层面的锁,是Java关键字,通过monitor对象来完成(monitorenter与monitorexit),ReentrantLock 是从jdk1.5以来(java.util.concurrent.locks.Lock)提供的API层面的锁。

2、实现原理:synchronized 的实现涉及到锁的升级,具体为无锁、偏向锁、自旋锁、向OS申请重量级锁;ReentrantLock实现则是通过利用CAS(CompareAndSwap)自旋机制保证线程操作的原子性和volatile保证数据可见性以实现锁的功能。

3、是否可手动释放: synchronized 不需要用户去手动释放锁,synchronized 代码执行完后系统会自动让线程释放对锁的占用; ReentrantLock则需要用户去手动释放锁,如果没有手动释放锁,就可能导致死锁现象

4、是否可中断synchronized是不可中断类型的锁,除非加锁的代码中出现异常或正常执行完成; ReentrantLock则可以中断,可通过trylock(long timeout,TimeUnit unit)设置超时方法或者将lockInterruptibly()放到代码块中,调用interrupt方法进行中断。

5、是否公平锁synchronized为非公平锁 ReentrantLock则即可以选公平锁也可以选非公平锁,通过构造方法new ReentrantLock时传入boolean值进行选择,为空默认false非公平锁,true为公平锁,公平锁性能非常低。

7、公平锁和非公平锁区别

公平锁:

公平锁自然是遵循FIFO(先进先出)原则的,先到的线程会优先获取资源,后到的会进行排队等待

优点: 所有的线程都能得到资源,不会饿死在队列中。适合大任务

缺点: 吞吐量会下降,队列里面除了第一个线程,其他的线程都会阻塞,cpu唤醒阻塞线程的开销大

非公平锁:

多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。

优点: 可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。

缺点: 你们可能也发现了,这样可能导致队列中间的线程一直获取不到锁或者长时间获取不到锁

公平锁效率低原因:

公平锁要维护一个队列,后来的线程要加锁,即使锁空闲,也要先检查有没有其他线程在 wait,如果有自己要挂起,加到队列后面,然后唤醒队列最前面线程。这种情况下相比较非公平锁多了一次挂起和唤醒

线程切换的开销,其实就是非公平锁效率高于公平锁的原因,因为非公平锁减少了线程挂起的几率,后来的线程有一定几率逃离被挂起的开销。

8、使用层面锁优化

【1】减少锁的时间: ​ 不需要同步执行的代码,能不放在同步快里面执行就不要放在同步快内,可以让锁尽快释放;

【2】减少锁的粒度: ​ 它的思想是将物理上的一个锁,拆成逻辑上的多个锁,增加并行度,从而降低锁竞争。它的思想也是用空间来换时间;java中很多数据结构都是采用这种方法提高并发操作的效率,比如:

ConcurrentHashMap:

java中的ConcurrentHashMap在jdk1.8之前的版本,使用一个Segment 数组:Segment< K,V >[] segments

Segment继承自ReenTrantLock,所以每个Segment是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。

【3】锁粗化: ​ 大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

【4】使用读写锁:

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

【5】使用CAS:

如果需要同步的操作执行速度非常快,并且线程竞争并不激烈,这时候使用cas效率会更高,因为加锁会导致线程的上下文切换,如果上下文切换的耗时比同步操作本身更耗时,且线程对资源的竞争不激烈,使用volatiled+cas操作会是非常高效的选择;

9、系统层面锁优化

自适应自旋锁:

自旋锁可以避免等待竞争锁进入阻塞挂起状态被唤醒造成的内核态和用户态之间的切换的损耗,它们只需要等一等(自旋),但是如果锁被其他线程长时间占用,一直不释放CPU,死等会带来更多的性能开销;自旋次数默认值是10

对上面自旋锁优化方式的进一步优化,它的自旋的次数不再固定,其自旋的次数由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定,这就解决了自旋锁带来的缺点

锁消除:

锁削除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行削除。Netty中无锁化设计pipeline中channelhandler会进行锁消除的优化。

锁升级:

偏向锁:

如果线程已经占有这个锁,当他在次试图去获取这个锁的时候,他会已最快的方式去拿到这个锁,而不需要在进行一些monitor操作,因为在大部分情况下是没有竞争的,所以使用偏向锁是可以提高性能的;

轻量级锁:

在竞争不激烈的情况下,通过CAS避免线程上下文切换,可以显著的提高性能。

重量级锁:

重量级锁的加锁、解锁过程造成的损耗是固定的,重量级锁适合于竞争激烈、高并发、同步块执行时间长的情况。

10、ThreadLocal原理

ThreadLocal简介:

通常情况下,我们创建的变量是可以被任何⼀个线程访问并修改的。如果想实现每⼀个线程都有⾃⼰的 专属本地变量该如何解决呢? JDK中提供的 ThreadLocal 类正是为了解决这样的问题。类似操作系统中的TLAB

原理:

首先 ThreadLocal 是一个泛型类,保证可以接受任何类型的对象。因为一个线程内可以存在多个 ThreadLocal 对象,所以其实是 ThreadLocal 内部维护了一个 Map ,是 ThreadLocal 实现的一个叫做 ThreadLocalMap 的静态内部类。

最终的变量是放在了当前线程的 ThreadLocalMap 中,并不是存在 ThreadLocal 上,ThreadLocal 可以理解为只是ThreadLocalMap的封装,传递了变量值。

我们使用的 get()、set() 方法其实都是调用了这个ThreadLocalMap类对应的 get()、set() 方法。例如下面的

如何使用:

1)存储用户Session

代码语言:javascript
复制
private static final ThreadLocal threadSession = new ThreadLocal();

2)解决线程安全的问题

代码语言:javascript
复制
private static ThreadLocal<SimpleDateFormat> format1 = new ThreadLocal<SimpleDateFormat>()

使用场景:

1、在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。

2、线程间数据隔离

3、进行事务操作,用于存储线程事务信息。

ThreadLocal内存泄漏的场景

实际上 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,⽽ value 是强引⽤。弱引用的特点是,如果这个对象持有弱引用,那么在下一次垃圾回收的时候必然会被清理掉。

所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。 假如我们不做任何措施的话,value 永远⽆法被GC 回收,如果线程长时间不被销毁,可能会产⽣内存泄露。

ThreadLocalMap实现中已经考虑了这种情况,在调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。如果说会出现内存泄漏,那只有在出现了 key 为 null 的记录后,没有手动调用 remove() 方法,并且之后也不再调用 get()、set()、remove() 方法的情况下。因此使⽤完ThreadLocal ⽅法后,最好⼿动调⽤ remove() ⽅法

11、HashMap线程安全

死循环造成 CPU 100%

HashMap 有可能会发生死循环并且造成 CPU 100% ,这种情况发生最主要的原因就是在扩容的时候,也就是内部新建新的 HashMap 的时候,扩容的逻辑会反转散列桶中的节点顺序,当有多个线程同时进行扩容的时候,由于 HashMap 并非线程安全的,所以如果两个线程同时反转的话,便可能形成一个循环,并且这种循环是链表的循环,相当于 A 节点指向 B 节点,B 节点又指回到 A 节点,这样一来,在下一次想要获取该 key 所对应的 value 的时候,便会在遍历链表的时候发生永远无法遍历结束的情况,也就发生 CPU 100% 的情况。

所以综上所述,HashMap 是线程不安全的,在多线程使用场景中推荐使用线程安全同时性能比较好的 ConcurrentHashMap。

12、CountDownLatch

CountDownLatch是一个同步工具类,它通过一个计数器来实现的,初始值为线程的数量。每当一个线程完成了自己的任务,计数器的值就相应得减1。当计数器到达0时,表示所有的线程都已执行完毕,然后在等待的线程就可以恢复执行任务。

CountDownLatch初始化一个全局计数器;如果想让某个线程处于等待中,该线程调用countdownLatch.await(),通过其他线程调用countdownLatch.countDown()减少计数器,直到减少到0,被await()挂起的线程恢复执行。可以实现1个线程等待一组线程执行完、实现一个线程释放一组线程、多个线程释放多个线程的场景。

方法详解

  • CountDownLatch(int count):count为计数器的初始值(一般需要多少个线程执行,count就设为几)。
  • countDown(): 每调用一次计数器值-1,直到count被减为0,代表所有线程全部执行完毕。
  • getCount():获取当前计数器的值。
  • await(): 等待计数器变为0,即等待所有异步线程执行完毕。
  • boolean await(long timeout, TimeUnit unit): 此方法与await()区别: ①此方法至多会等待指定的时间,超时后会自动唤醒,若 timeout 小于等于零,则不会等待 ②boolean 类型返回值:若计数器变为零了,则返回 true;若指定的等待时间过去了,则返回 false

在实时系统中的使用场景

  • 实现最大的并行性:有时我们想同时启动多个线程,实现最大程度的并行性。例如,我们想测试一个单例类。如果我们创建一个初始计数器为1的CountDownLatch,并让其他所有线程都在这个锁上等待,只需要调用一次countDown()方法就可以让其他所有等待的线程同时恢复执行。
  • 开始执行前等待N个线程完成各自任务:例如应用程序启动类要确保在处理用户请求前,所有N个外部系统都已经启动和运行了。
  • 死锁检测:一个非常方便的使用场景是你用N个线程去访问共享资源,在每个测试阶段线程数量不同,并尝试产生死锁。
13、CyclicBarrier

CyclicBarrier 可以理解为循环栅栏, Cyclic 意为循环,也就是说这个计数器可以反复使用。

CyclicBarrier通常称为循环屏障。它和CountDownLatch很相似,都可以使线程先等待然后再执行。不过CountDownLatch是使一批线程等待另一批线程执行完后再执行;而CyclicBarrier只是使等待的线程达到一定数目后再让它们继续执行。故而CyclicBarrier内部也有一个计数器,计数器的初始值在创建对象时通过构造参数指定,如下所示:

代码语言:javascript
复制
public CyclicBarrier(int parties) {
    this(parties, null);
}

每调用一次await()方法都将使阻塞的线程数+1,只有阻塞的线程数达到设定值时屏障才会打开,允许阻塞的所有线程继续执行。除此之外,CyclicBarrier还有几点需要注意的地方:

  • CyclicBarrier的计数器可以重置而CountDownLatch不行,这意味着CyclicBarrier实例可以被重复使用而CountDownLatch只能被使用一次。而这也是循环屏障循环二字的语义所在。
  • CyclicBarrier允许用户自定义barrierAction操作,这是个可选操作,可以在创建CyclicBarrier对象时指定
代码语言:javascript
复制
指定本局要拦截的线程数parties 及 本局结束时要执行的任务
public CyclicBarrier(int parties, Runnable barrierAction) {
    if (parties <= 0) throw new IllegalArgumentException();
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}

一旦用户在创建CyclicBarrier对象时设置了barrierAction参数,则在阻塞线程数达到设定值屏障打开前,会调用barrierAction的run()方法完成用户自定义的操作。

常用方法

代码语言:javascript
复制
//参数parties:表示要到达屏障 (栅栏)的线程数量
//参数Runnable: 最后一个线程到达屏障之后要做的任务
​
//构造方法1
public CyclicBarrier(int parties) 
//构造方法2 指定本局要拦截的线程数parties 及 本局结束时要执行的任务
public CyclicBarrier(int parties, Runnable barrierAction)
​
//线程调用await()方法表示当前线程已经到达栅栏,然后会被阻塞
public int await() throws InterruptedException, BrokenBarrierException {
    try {
        return dowait(false, 0L);
    } catch (TimeoutException toe) {
        throw new Error(toe); // cannot happen
    }
}
​
//带时限的阻塞等待
public int await(long timeout, TimeUnit unit) throws InterruptedException,BrokenBarrierException,TimeoutException {
    return dowait(true, unit.toNanos(timeout));
}
14、Semaphore

Semaphore是JDK提供的一个同步工具,它通过维护若干个许可证来控制线程对共享资源的访问。 如果许可证剩余数量大于零时,线程则允许访问该共享资源;如果许可证剩余数量为零时,则拒绝线程访问该共享资源。 Semaphore所维护的许可证数量就是允许访问共享资源的最大线程数量。 所以,线程想要访问共享资源必须从Semaphore中获取到许可证。 常用方法:

代码语言:javascript
复制
//默认获取一个许可
public void acquire() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }
//默认释放一个许可
public void release() {sync.releaseShared(1); }
//获取指定的许可数    
public void acquire(int permits) throws InterruptedException {
        if (permits < 0) throw new IllegalArgumentException();
        sync.acquireSharedInterruptibly(permits);
    }
//尝试获取指定的许可数    
public boolean tryAcquire(int permits) {
        if (permits < 0) throw new IllegalArgumentException();
        return sync.nonfairTryAcquireShared(permits) >= 0;
    }
//释放指定的许可数    
public void release(int permits) {
        if (permits < 0) throw new IllegalArgumentException();
        sync.releaseShared(permits);
    }

当调用acquire方法时线程就会被阻塞,直到Semaphore中可以获得到许可证为止,然后线程再获取这个许可证。 当调用release方法时将向Semaphore中添加一个许可证,如果有线程因为获取许可证被阻塞时,它将获取到许可证并被释放;如果没有获取许可证的线程, Semaphore只是记录许可证的可用数量。 张三、李四和王五和赵六4个人一起去饭店吃饭,不过在特殊时期洗手很重要,饭前洗手也是必须的,可是饭店只有2个洗手池,洗手池就是不能被同时使用的公共资源,这种场景就可以用到Semaphore。

代码语言:javascript
复制
package onemore.study.semaphore;
​
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
​
public class Customer implements Runnable {
    private Semaphore washbasin;
    private String name;
​
    public Customer(Semaphore washbasin, String name) {
        this.washbasin = washbasin;
        this.name = name;
    }
​
    @Override
    public void run() {
        try {
            SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss.SSS");
            Random random = new Random();
​
            washbasin.acquire();
            System.out.println(
                sdf.format(new Date()) + " " + name + " 开始洗手...");
            Thread.sleep((long) (random.nextDouble() * 5000) + 2000);
            System.out.println(
                sdf.format(new Date()) + " " + name + " 洗手完毕!");
            washbasin.release();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
//测试类
package onemore.study.semaphore;
​
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Semaphore;
​
public class SemaphoreTester {
    public static void main(String[] args) throws InterruptedException {
        //饭店里只用两个洗手池,所以初始化许可证的总数为2。
        Semaphore washbasin = new Semaphore(2);
​
        List<Thread> threads = new ArrayList<>(3);
        threads.add(new Thread(new Customer(washbasin, "张三")));
        threads.add(new Thread(new Customer(washbasin, "李四")));
        threads.add(new Thread(new Customer(washbasin, "王五")));
        threads.add(new Thread(new Customer(washbasin, "赵六")));
        for (Thread thread : threads) {
            thread.start();
            Thread.sleep(50);
        }
​
        for (Thread thread : threads) {
            thread.join();
        }
    }
}
​

运行结果

代码语言:javascript
复制
06:51:54.416 李四 开始洗手...
06:51:54.416 张三 开始洗手...
06:51:57.251 张三 洗手完毕!
06:51:57.251 王五 开始洗手...
06:51:59.418 李四 洗手完毕!
06:51:59.418 赵六 开始洗手...
06:52:02.496 王五 洗手完毕!
06:52:06.162 赵六 洗手完毕!

内部原理

Semaphore内部主要通过AQS(AbstractQueuedSynchronizer)实现线程的管理。Semaphore在构造时,需要传入许可证的数量,它最后传递给了AQS的state值。线程在调用acquire方法获取许可证时,如果Semaphore中许可证的数量大于0,许可证的数量就减1,线程继续运行,当线程运行结束调用release方法时释放许可证时,许可证的数量就加1。如果获取许可证时,Semaphore中许可证的数量为0,则获取失败,线程进入AQS的等待队列中,等待被其它释放许可证的线程唤醒。 深入原理:

代码中,这4个人会按照线程启动的顺序洗手嘛?

不会按照线程启动的顺序洗手,有可能赵六比王五先洗手。

原因:使用Semaphore的构造函数是这个:

代码语言:javascript
复制
public Semaphore(int permits) {
    sync = new NonfairSync(permits);
}

在这个构造函数中,使用的是NonfairSync(非公平锁),这个类不保证线程获得许可证的顺序,调用acquire方法的线程可以在一直等待的线程之前获得一个许可证。

有没有什么方法可保证他们的顺序?

可以使用Semaphore的另一个构造函数:

代码语言:javascript
复制
public Semaphore(int permits, boolean fair) {
    sync = fair ? new FairSync(permits) : new NonfairSync(permits);
}

在调用构造方法时,fair参数传入true,比如:

代码语言:javascript
复制
Semaphore washbasin = new Semaphore(2, true);

这样使用的是FairSync(公平锁),可以确保按照各个线程调用acquire方法的顺序获得许可证。

内存模型

Java 内存模型(Java Memory Model,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

JMM 是一种规范,是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。目的是保证并发编程场景中的原子性、可见性和有序性。

原子性:

在 Java 中,为了保证原子性,提供了两个高级的字节码指令 Monitorenter 和 Monitorexit。这两个字节码,在 Java 中对应的关键字就是 Synchronized。因此,在 Java 中可以使用 Synchronized 来保证方法和代码块内的操作是原子性的。

可见性:

Java 中的 Volatile 关键字修饰的变量在被修改后可以立即同步到主内存。被其修饰的变量在每次使用之前都从主内存刷新。因此,可以使用 Volatile 来保证多线程操作时变量的可见性。除了 Volatile,Java 中的 Synchronized 和 Final 两个关键字也可以实现可见性。只不过实现方式不同

有序性

在 Java 中,可以使用 Synchronized 和 Volatile 来保证多线程之间操作的有序性。区别:Volatile 禁止指令重排。Synchronized 保证同一时刻只允许一条线程操作。

1、volatile底层实现

作用:

1.可见性: 当一个线程修改了volatile修饰的变量的值,其他线程可以立即看到这个修改,保证了共享变量的可见性。

2.禁止指令重排序: 编译器和处理器在编译和执行代码时,可能会对指令进行重排序,但是volatile关键字可以禁止这种重排序,保证了程序的正确性。

3.保证原子性: volatile关键字可以保证一些简单的操作的原子性,例如++操作,但是对于复合操作,volatile关键字无法保证原子性。

底层实现:

“观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;

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

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

单例模式中volatile的作用:(DCL(Double Check Lock)单例为什么要加Volatile?)

防止代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。

volatile防止指令重排,在DCL中,防止高并发情况下,指令重排造成的线程安全问题。

代码语言:javascript
复制
class Singleton{
    private volatile static Singleton instance = null;   //禁止指令重排
    private Singleton() {
         
    }
    public static Singleton getInstance() {
        if(instance==null) { //减少加锁的损耗
            synchronized (Singleton.class) {
                if(instance==null) //确认是否初始化完成
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

volatile关键字和synchronized关键字的区别

  1. volatile关键字保证了共享变量的可见性和禁止指令重排序,但是无法保证原子性,而synchronized关键字可以保证原子性、有序性和可见性。
  2. volatile关键字适用于一些简单的操作,例如++操作,而synchronized关键字适用于复合操作。
  3. volatile关键字不会造成线程阻塞,而synchronized关键字可能会造成线程阻塞。

volatile能不能保证线程安全?

volatile不能保证线程安全,只能保证线程可见性,不能保证原子性

flag常量未使用volatile关键字的时候,程序一直不停止,因为无法感知flag的变化

使用volatile关键字后,因为volatile保证了可见性,感知到了flag的变化,程序停止并打印

2、AQS思想

AQS的全称为(AbstractQueuedSynchronizer)抽象的队列式的同步器,是⼀个⽤来构建锁和同步器的框架,使⽤AQS能简单且⾼效地构造出应⽤⼴泛的⼤量的同步器,如:基于AQS实现的lock, CountDownLatch、CyclicBarrier、Semaphore需解决的问题:

  • 状态的原子性管理
  • 线程的阻塞与解除阻塞
  • 队列的管理

AQS核⼼思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的⼯作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占⽤,那么就需要⼀套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是⽤CLH(虚拟的双向队列) 队列锁实现的,即将暂时获取不到锁的线程加⼊到队列中。

lock:

是一种可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应中断锁、可轮询锁请求、定时锁等避免多线程死锁的方法。默认为非公平锁,但可以初始化为公平锁; 通过方法 lock()与 unlock()来进行加锁与解锁操作;

常见AQS锁:

CountDownLatch(倒计数器):

通过计数法(倒计时器),让一些线程堵塞直到另一个线程完成一系列操作后才被唤醒;该⼯具通常⽤来控制线程等待,它可以让某⼀个线程等待直到倒计时结束,再开始执⾏。具体可以使用countDownLatch.await()来等待结果。多用于多线程信息汇总。

CompletableFuture(独占锁):

通过设置参数,可以完成CountDownLatch同样的多平台响应问题,但是可以针对其中部分返回结果做更加灵活的展示。

CyclicBarrier(循环栅栏):

字面意思是可循环(Cyclic)使用的屏障(Barrier)。他要做的事情是,让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续干活,线程进入屏障通过CyclicBarrier的await()方法。可以用于批量发送消息队列信息、异步限流。

Semaphore(信号量):

信号量主要用于两个目的,一个是用于多个共享资源的互斥作用,另一个用于并发线程数的控制。SpringHystrix限流的思想

为什么AQS使用的双向链表

因为有一些线程可能发生中断 ,而发生中断时候就需要在同步阻塞队列中删除掉,这个时候用双向链表方便删除掉中间的节点

3、happens-before

用来描述和可见性相关问题:如果第一个操作 happens-before 第二个操作,那么我们就说第一个操作对于第二个操作是可见的

常见的happens-before:volatile 、锁、线程生命周期。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-10-12,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 线程安全
    • 1、产生死锁的原因
      • 2、产生死锁的四个必要条件
        • 3、乐观锁,CAS思想
          • 4、悲观锁
            • 5、synchronized底层实现
              • 6、ReenTrantLock底层实现
                • 7、公平锁和非公平锁区别
                  • 8、使用层面锁优化
                    • 9、系统层面锁优化
                      • 10、ThreadLocal原理
                        • 11、HashMap线程安全
                          • 12、CountDownLatch
                            • 13、CyclicBarrier
                              • 14、Semaphore
                              • 内存模型
                                • 1、volatile底层实现
                                  • 2、AQS思想
                                    • 3、happens-before
                                    相关产品与服务
                                    领券
                                    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档