volatile+synchronized+threadlocal+cas自旋+死锁及解决方式(银行家算法等)

目录

一、volatile

二、synchronized

1.Synchronized是如何从重量级锁升级为经量级锁的?

1.1.什么是锁膨胀/锁升级?

1.无锁

2.偏向锁

3.轻量级锁(乐观锁)(自旋锁)

4.重量级锁(悲观锁)

1.2.锁消除

1.3.锁粗化

1.4自适应自旋锁(乐观锁、轻量级锁)

2.Synchronized关键字有几种锁定方式/几种用法,及其作用范围?

3.Synchronized修饰的方法抛出异常时,会自动释放锁吗?

4.什么是公平锁和非公平锁?

公平锁

非公平锁

Java中的公平锁和非公平锁

1.ReentrantLock(可重入锁)

1)基本使用

2)可重入

3)可中断

3.1)补充:Thread.interrupt()

3.2)补充:多线程--Park&Unpark

3.3)复习:wait 、notify、notifyAll,sleep、yield、wait、join

3.4)正题-可中断:lockInterruptibly()

4)tryLock()--获取锁超时--可中断

tryLock()

5)条件变量的使用

2.ReentrantReadWriteLock(可重入读写锁)

3.StampedLock(该锁不允许重入)

4.synchronized(非公平锁)

5.synchronized是公平锁还是非公平锁?

6.synchronized底层源码如何实现?

7.

三、死锁-及其解决方式

1.什么是死锁

2.本质原因

3.产生死锁的4个必要条件

1、互斥条件

2、不可剥夺条件(不能被动释放)

3、请求与保持/占有并等待条件(不主动释放)

4、循环等待条件

4、处理死锁的方法

1.预防死锁(资源预先静态分配法、资源顺序分配法)

破坏“互斥”条件:

破坏“请求与保持/占有并等待”条件:

破坏“不可抢占”条件:

破坏“循环等待”条件(资源顺序分配法):

2.避免死锁(银行家算法)

3.检测死锁(资源分配图简化法)

4.解除死锁(资源剥夺法、撤销进程法、进程回退法)

资源剥夺法:

撤销进程法:

进程回退法:

四、CAS自旋

我们先看一段代码:

所以面对这种情况,我们就可以使用java中的“原子操作类”:

而Atomic操作类的底层正是用到了“CAS机制”:

CAS的缺点:

1) CPU开销过大

2) 不能保证代码块的原子性

3) ABA问题

什么是ABA问题:

如何解决ABA问题:

Java语言的CAS底层如何实现:

五、ThreadLocal


一、volatile

当一个变量定义为 volatile 之后,将具备两种特性:

1.保证此变量对所有的线程的可见性。这里的“可见性”,如本文开头所述,变量的值在线程间传递均需要通过主内存(详见:Java内存模型)来完成。当一个线程修改了这个变量的值,volatile 保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。但普通变量做不到这点。

2.禁止指令重排序即有序性。有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个内存屏障(指令重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障;(什么是指令重排序:是指CPU允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理)。

3.volatile 性能: volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行

        volatile关键字的作用简单来说就是保证了Java并发编程中的可见性、有序性,但不能保证原子性。synchronized可以保证并发编程的可见性、有序性、原子性,即并发程序的安全,但是性能会差些。虽然volatile关键字可以保证变量的可见性和禁止指令重排,但并不能保证线程安全。

volatile究竟能否保证线程安全性?

  答:在某些特定的情况下能。那到底什么是什么能,什么时候又不能了呢?我们继续往下看。

  (1)volatile能保证线程安全的情况

  要使 volatile 变量提供理想的线程安全性,必须同时满足两个条件:

       ①对变量的写操作不依赖于当前变量值(volatile不保证i=i+1、i++的线程安全)。

       ②也不依赖于其它变量值,该变量没有包含在具有其他变量的不变式中。

  这两个条件使得 volatile 变量不能像 synchronized 那样普遍适用于实现线程安全。

  (2)volatile不能保证线程安全的情况

  除了(1)中提到的能够使volatile发挥保证线程安全性的情况,其他情况下volatile并不能保证线程安全问题,因为volatile并不能保证变量操作的原子性。

       我们要先明确一个定律线程对变量的所有操作(取值、赋值等)都必须在工作内存(各线程独立拥有)中进行,而不能直接读写主存中的变量,各工作内存间也不能相互访问。对于volatile变量来说,由于它特殊的操作有序性规定,看起来如同操作主内存一般,但实际上 volatile变量也是遵循这一定律的。

  关于主存与工作内存之间具体的交互协议(即一个变量如何从主存拷贝到工作内存、如何从工作内存同步到主存等实现细节),Java内存模型中定义了以下八种操作来完成:

lock:(锁定),unlock(解锁),read(读取),load(载入),use(使用), assign(赋值),store(存储),write(写入)

  volatile 对这八种操作有着两个特殊的限定,正因为有这些限定才让volatile修饰的变量有可见性以及可以禁止指令重排序 :

  ① use动作之前必须要有read和load动作, 这三个动作必须是连续出现的。【表示:每次工作内存要使用volatile变量之前必须去主存中拿取最新的volatile变量】

  ② assign动作之后必须跟着store和write动作,这三个动作必须是连续出现的。【表示: 每次工作内存改变了volatile变量的值,就必须把该值写回到主存中】

  有以上两条规则就能保证每个线程每次去拿volatile变量的时候,那个变量肯定是最新的, 其实也就相当于好多个线程用的是同一个内存,无工作内存和主存之分。而操作没有用volatile修饰的变量则不能保证每次都能获取到最新的变量值。

volatile 不用来修饰数组或对象,用来修饰基本数据类型, 如果volatile修饰数组,那么volatile只能对数组引用本身起作用,无法对数组中的元素起作用 

volatile能保证可见性,但不能保证原子性。volatile在多线程写场景下只是保证共享变量的可见性,不保证线程对共享变量的操作的原子性,不能保证线程安全。如i++。 

若要保证线程安全请用java.util.concurrent.atomic包下的

AtomicInteger、AtomicBoolean…、AtomicReference、AtomicReferenceArray等原子包装类(底层正是volatile+CAS自旋机制)。既涵盖了volatile的作用(可见性、有序性),又保证了原子性,线程安全,自旋比加锁开销小。 

AtomicReference在实现上用volatile来保证可见性,用Unsafe.compareAndSwapObject()来保证原子性,作用是对普通对象的封装。

以下是AtomicReference的一些常用方法:

  • get():获取当前引用对象的值。
  • set(T newValue):设置新的引用对象的值。
  • compareAndSet(T expect, T update):如果当前引用对象的值等于expect,则将其更新为update。
  • getAndSet(T newValue):设置新的引用对象的值,并返回旧的引用对象的值。

每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中;
那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

但是用volatile修饰之后就变得不一样了:
一:使用volatile关键字会强制将线程2修改的值立即写入主存
二:使用volatile关键字的话,当线程2进行修改时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入主存),会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取,它会等待缓存行对应的主存地址的值被更新之后,去读取最新的值。

那么为什么volatile关键字不保证原子性呢?

以i++为例子:分为三步:读 计算 写

首先主存中有一个变量i = 1。

线程1从主存中读取变量i,并拷贝一份i = 1的副本到自己的高速缓存中。接下来执行i+1操作。自己副本中i = 2。接下来本应该强制将修改的值立即写入主存,但是时间片用完了(时间片用完了就必须停止,这里还没有来得及写入主存),线程1阻塞了

然后线程2从主存中读取变量i,并拷贝一份i = 1的副本到自己的高速缓存中。接下来执行i+1操作,自己副本中的i = 2。接下来将i = 2写入主存。在写入主存过程中会通过一个总线嗅探机制告诉其它线程的副本i,你失效了。

过一会线程1又分配到了时间片,这个时候应该强制将修改的值立即写入主存,但是去高速缓存中拿值的时候发现自己的副本已经被标记失效了。然后就得重新去主存中拿i值。重新到主存中拿到的值i = 2。最后把i = 2写入主存。(读和计算的操作是已经执行过了的命令,所以它不会重新计算了,它只差写操作的命令)

本来是两个线程,分别对i执行+1操作,正确值应该为3,但是最终为2,显然是有问题的。

所以volatile只能保证变量的可见性,不能保证原子性。

i++是三个原子操作组合而成的复合操作。它本身就不是原子操作,不能依靠volatile这个保证可见性和有序性的关键字来保证原子性

“总线嗅探机制”

CPU内存通过总线(BUS)互通消息。CPU感知其他CPU的行为(比如读、写某个缓存行)也是通过嗅探(Snoop)总线中其他CPU发出的消息完成的。


二、synchronized

Synchronized是Java提供的一种同步机制,它能够保证在同一时刻只有一个线程能够进入被synchronized修饰的代码块或方法,从而避免了多个线程同时访问共享资源的问题,保证了线程安全和程序的正确性。synchronized是一种轻量级锁(仍可升级到重量级锁

但在JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时CPU都需要用户态和内核态的切换,而转换的效率是比较低的。(内核态:cpu可以访问内存的所有数据,包括外围设备,例如硬盘,网卡,cpu也可以将自己从一个程序切换到另一个程序。 用户态:只能受限的访问内存,且不允许访问外围设备。)

1.Synchronized是如何从重量级锁升级为经量级锁的?

Synchronized 核心优化方案主要包含以下 4 个:

  • 锁膨胀

  • 锁消除

  • 锁粗化

  • 自适应自旋锁

其中锁膨胀和自适应自旋锁synchronized 关键字自身的优化实现,而锁消除和锁粗化JVM 虚拟机对 synchronized 提供的优化方案,这些优化方案最终使得 synchronized 的性能得到了大幅的提升,也让它在并发编程中占据了一席之地。

1.1.什么是锁膨胀/锁升级?

锁膨胀是指 synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫锁膨胀也叫锁升级。

JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁仍然有重量级锁,这时候在进行并发操作时,大部分的场景都不需要用户态和内核态的切换了,这样就大幅的提升了 synchronized 的性能。

1.无锁

没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。

2.偏向锁

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

3.轻量级锁(乐观锁)(自旋锁)

是指当锁是偏向锁的时候,被另外的线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋(例如cas机制)的形式尝试获取锁,不会阻塞,从而提高性能。synchronized现在就是轻量级锁。

4.重量级锁(悲观锁)

指的是原始的Synchronized的实现,重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程

1.2.锁消除

锁消除指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。

锁消除的依据是逃逸分析(是一个很重要的 JIT 优化技术,它的作用是判断对象是否会在方法外部被访问到,也就是逃出方法的作用域Java JIT(Just-In-Time)编译器是JVM中的一个组件,它负责将字节码转换为机器码(字节码和机器码都是二进制),并且根据程序的实际运行情况对机器码进行优化,从而提高程序运行的速度。字节码在JVM中运行,机器码可以直接被CPU执行的数据支持,如 StringBuffer 的 append() 方法,或 Vector 的 add() 方法,在很多情况下是可以进行锁消除的,比如以下这段代码:

public String method() {
    StringBuffer sb = new StringBuffer();
    for (int i = 0; i < 10; i++) {
        sb.append("i:" + i);
    }
    return sb.toString();
}

以上代码经过编译之后的字节码如下:

从上述结果可以看出,之前我们写的线程安全的加锁的 StringBuffer 对象,在生成字节码之后就被替换成了不加锁不安全的 StringBuilder 对象了,原因是 StringBuffer 的变量属于一个局部变量,并且不存在多线程竞争该变量的条件,所以JVM就可以使用锁消除(不加锁)来加速程序的运行。

1.3.锁粗化

锁粗化是指,JVM检测到程序的某处执行了连续的加锁和解锁操作,可能会降低程序执行效率,便将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,从而提升程序的执行效率。

我只听说“锁细化”可以提高程序的执行效率,也就是将锁的范围尽可能缩小,这样被同步的代码块就会变小,从而提高程序的运行效率,但锁粗化是如何提高性能的呢?

没错,锁细化的观点在大多数情况下都是成立了,但是一系列连续加锁和解锁的操作,也会导致不必要的性能开销,从而影响程序的执行效率,比如这段代码:

public String method() {
StringBuilder sb = new StringBuilder();
  for (int i = 0; i < 10; i++) {
    // 伪代码:加锁操作
    sb.append( + i);
    // 伪代码:解锁操作
  }
  return sb.toString();
}

这里我们不考虑编译器优化的情况,如果在 for 循环中定义锁,那么锁的范围很小,但每次 for 循环都需要进行加锁和释放锁的操作,性能是很低的;但如果我们直接在 for 循环的外层加一把锁,那么对于同一个对象操作这段代码的性能就会提高很多,如下伪代码所示:

public String method() {
StringBuilder sb = new StringBuilder();
    // 伪代码:加锁操作
    for (int i = 0; i < 10; i++) {
        sb.append( + i);
    }
    // 伪代码:解锁操作
    return sb.toString();
}

1.4自适应自旋锁(乐观锁、轻量级锁)

自旋锁是指通过自身循环,尝试获取锁的一种方式,伪代码实现如下:

// 尝试获取锁
while(!isLock()){
  ......
}

(自旋锁是乐观锁、轻量级锁),自旋锁优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要用户态和内核态的切换,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销

但是,如果长时间自旋还获取不到锁,那么也会造成一定的资源浪费,所以我们通常会给自旋次数设置一个固定的值来避免一直自旋的性能开销。然而对于 synchronized 关键字来说,它的自旋锁更加的“智能”,synchronized 中的自旋锁是自适应自旋锁,这就好比之前一直开的手动挡的三轮车,而经过了 JDK 1.6 的优化之后,我们的这部“车”,一下子变成自动挡的兰博基尼了。

自适应自旋锁是指,线程自旋的次数不再是固定的值,而是一个动态改变的值,这个值会根据前一次自旋获取锁的状态来决定此次自旋的次数比如上一次通过自旋成功获取到了锁,那么这次通过自旋也有可能会获取到锁,所以这次自旋的次数就会增多一些,而如果上一次通过自旋没有成功获取到锁,那么这次自旋可能也获取不到锁,所以为了避免资源的浪费,就会少循环或者不循环,以提高程序的执行效率。简单来说,如果线程自旋成功了,则下次自旋的次数会增多,如果失败,下次自旋的次数会减少。

2.Synchronized关键字有几种锁定方式/几种用法,及其作用范围?

在Java中,synchronized关键字可以用于三种不同的锁定方式,分别是锁定静态方法、锁定非静态方法和锁定代码块。它们各自的作用范围如下:

Synchronized使用形式
  • 对于普通同步方法,锁对象是当前实例对象
  • 对于静态同步方法,锁对象是当前类的Class对象
  • 对于同步方法块,锁对象是Synchonized括号里配置的对象

锁定静态方法
使用synchronized修饰的静态方法,synchronized的作用范围是该类的所有实例对象。也就是说,当多个线程同时调用该类的不同实例对象的同一个静态方法时,它们会被阻塞,直到该方法执行完毕。而当多个线程同时调用该类的同一实例对象的同一个静态方法时,它们也会被阻塞,直到该方法执行完毕。

锁定非静态方法
使用synchronized修饰的非静态方法,锁定的是该对象本身的所有同步方法和同步代码块。也就是说,当多个线程同时调用该对象的同步方法时,它们会被阻塞,直到该方法执行完毕。而当多个线程同时调用该对象非同步方法时,它们不会被阻塞,可以同时执行。

锁定代码块
使用synchronized修饰的代码块,锁定的是该对象本身的同步代码块内部的所有代码。也就是说,当多个线程同时调用该对象的同步代码块时,它们会被阻塞,直到该代码块执行完毕。而当多个线程同时调用该对象的非同步代码块时,它们不会被阻塞,可以同时执行。

任意一个非 NULL 的对象都可以作为synchronized的锁(同步监视器)。

3.Synchronized修饰的方法抛出异常时,会自动释放锁吗?

当使用Synchronized修饰的方法在执行过程中出现异常时,JVM会自动释放该线程所持有的锁以确保其他线程可以获取到该锁,继续执行相应的代码块,从而避免了死锁的情况。这种机制称为锁的释放。需要注意的是,锁释放是自动发生的,程序员不需要手动编写相关代码来释放锁。

4.什么是公平锁和非公平锁?

公平锁

是指多个线程按照申请锁的顺序来获取锁,即先到先得的策略。当一个线程释放锁之后,等待时间最长的线程将获得锁。公平锁的优点是保证了每个线程的公平性,不存在饥饿现象,但是由于需要维护一个等待队列,因此会增加系统的开销。

非公平锁

是指多个线程获取锁的顺序是不确定的,不一定按照申请锁的顺序来获取锁。当一个线程释放锁之后,锁的获取是由系统的调度算法来决定的。优点是可以减少线程切换的开销,提高系统的吞吐量,但是容易出现饥饿现象,即某些线程可能会一直获取不到锁。

一般来说,公平锁适用于对线程公平性要求比较高的场景,而非公平锁适用于对性能要求比较高的场景。

Java中的公平锁和非公平锁

1.ReentrantLock(可重入锁)

java.util.concurrent.locks包中ReentrantLock类的公平锁和非公平锁:ReentrantLock是Java中常用的锁实现类之一,​​它提供了公平锁和非公平锁两种模式。在创建ReentrantLock对象时,可以通过构造函数传入一个布尔类型的fair参数来指定锁的模式,如果fair为true,则创建公平锁;如果fair为false,则创建非公平锁。​​

ReentrantLock是可重入的、可中断的,它是一个实现了Lock接口的类,下面介绍它的使用方式及常用的方法。

1)基本使用

下面是创建一个锁然后获取再释放的一个过程,可以看到需要调用unlock()方法释放锁,为了防止程序发生异常导致不能释放锁造成程序的死锁,一般要把释放锁的代码写在finally块中,确保锁的释放。

public class Test02 {
    private static final ReentrantLock lock = new ReentrantLock();
    public static void main(String[] args){
        try {
            // 获取锁
            lock.lock();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}
2)可重入
public class Test02 {
    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args){
        // 获取锁
        lock.lock();
        try {
            System.out.println("main方法中获取到了锁..");
            m1();// 获取锁、释放锁
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            lock.unlock();
            System.out.println("main方法中释放到了锁..");
        }
    }

    public static void m1(){
        // 获取锁
        lock.lock();
        try {
            System.out.println("m1方法中获取到了锁..");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            lock.unlock();
            System.out.println("m1方法中释放了锁..");
        }
    }
}

​上面的代码和输入结果证明,在一个线程中,main方法没有释放锁,m1方法就能获取到锁,由此证明了ReentrantLock的可重入性。

可重入锁使用使用场景(防止重复触发):

每一个锁关联一个线程持有者和计数器,当计数器为 0 时表示该锁没有被任何线程持有,那么任何线程都可能获得该锁而调用相应的方法;当某一线程请求成功后,JVM会记下锁的持有线程,并且将计数器置为 1;此时其它线程请求该锁,则必须等待;而该持有锁的线程如果再次请求这个锁,就可以再次拿到这个锁,同时计数器会递增;当线程退出同步代码块时,计数器会递减,如果计数器为 0,则释放该锁。

  1. 例如,在定时任务的系统中,如果某个任务已经处于执行状态,那么在执行它的下一个任务之前,应该检查是否已经有任务正在执行,以避免重复执行。同样,对于用户界面的交互,如果一个请求操作可能需要很长时间才能完成,为了避免因为多次点击而导致的后台重复执行,也可以使用可重入锁来控制同一操作的并发执行次数。
  2. 在一些情况下,我们不希望同一个操作被重复执行,即使它已经被触发过。这通常发生在一些依赖于特定顺序或者条件的事件处理程序中,比如在数据库事务管理中,或者在消息队列的处理逻辑中,都需要保证操作的原子性和顺序性,从而避免数据的不一致或丢失。在这种情况下,可重入锁可以用来保护临界区资源,确保只有一次性的操作会被执行。
3)可中断

当有线程竞争锁的时候,当获取锁失败时,线程会进入阻塞,等待锁释放时被唤醒

3.1)补充:Thread.interrupt()

Thread t1 = new Thread(() -> {

        . . . . . . . . .
}, "t1");
t1.start();        t1.interrupt();  、t2.interrupt();   、 t3.interrupt();

作用是通知线程(调用interrupt()的线程)应该中断了,到底中断还是继续运行,应该由被通知的线程自己决定怎么处理。当对一个线程,调用 interrupt() 时,

  1.如果线程处于被阻塞状态(例如处于sleep, wait, join 等状态),那么线程将立即退出被阻塞状态,并抛出一个InterruptedException异常,并将中断状态Interrupt flag重新置为false。

  2.如果线程处于运行状态,那么会将该线程的中断标志设置为 true。被设置中断标志的线程不会抛出异常信息,将不受影响继续正常运行,执行完毕会正常释放锁。如果该线程想停止,则检测到打断状态为true进行停止即可。

我们需要注意的是,Java中凡是抛出InterruptedException时(再加上调用Thread.interrupted()方法时),都会将interrupt flag重新置为false。

3.2)补充:多线程--Park&Unpark

它们是 java.util.concurrent.locks.LockSupport 类中的方法

// 暂停当前线程
LockSupport.park();
// 恢复某个线程的运行
LockSupport.unpark(暂停的线程对象);

先 park 再 unpark

package com.genertech.plm.aia.portaladmin.service.impl;

import java.util.concurrent.locks.LockSupport;

public class A {
  public static void main(String[] args) {
    Thread t1 = new Thread(() -> {
      System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " start......");
      try {
        Thread.sleep(1000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
      System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " park");
      LockSupport.park();//【park(t1)】
      System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " resume");
    }, "t1");
    t1.start();

    try {
      Thread.sleep(5000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    System.out.println(System.currentTimeMillis() + " " + Thread.currentThread().getName() + " unpark......");
    LockSupport.unpark(t1);//【unpark(t1)】
  }
}

特点

  • 与 Object 的 wait & notify 相比,wait ,notify,notifyAll 必须配合synchronized的 Object Monitor 一起使用,而 park & unpark 不必。
  • unpark 更精确,以线程为单位来【唤醒】线程,而 notify notifyAll 只能随机唤醒一个或全部唤醒。
3.3)复习:wait 、notify、notifyAll,sleep、yield、wait、join

sleep会释放锁吗

不会

`sleep` 方法不会释放锁。当线程调用 `sleep` 方法时,它会进入睡眠状态,但并不会释放它持有的锁资源。`sleep` 方法的作用是让当前线程让出 CPU 执行时间片进入阻塞状态暂停执行一定时间,并,而不是释放同步资源锁。因此,如果一个线程在睡眠时持有锁,它仍然会持有锁,直到睡眠结束。需要注意的是,`sleep` 方法不要求线程持有任何锁,也就是说,它可以在任何地方被调用。与之相对的是 `wait` 方法,当一个线程调用 `wait` 方法时,它会释放它持有的锁,并等待被其他线程使用 `notify` 或 `notifyAll` 唤醒。因此,如果一个线程持有锁,它会在 `wait` 后释放锁。

yield会释放锁吗

不会

`yield` 方法在执行后,确实会让当前执行的线程释放CPU的执行权,并将其置于就绪状态这意味着线程不再占用 CPU 时间片,但并没有释放它持有的锁资源。当一个线程 `yield` 后,CPU 可以选择重新执行另一个线程,包括之前 `yield` 的线程。因此,`yield` 不会直接导致锁的释放,它仅仅是将当前线程的状态调整为就绪状态,以便其他线程有机会执行。锁的释放取决于线程是否调用了 `lock` 方法以及是否在 `try-with-resources` 语句中调用了 `unlock` 方法进行了资源的释放。

3.4)正题-可中断:lockInterruptibly()

ReentrantLock要想实现可中断特性,不可以调用lock()方法,需要调用 lockInterruptibly() 方法。由于lockI()、lockInterruptibly()的声明中抛出了异常,所以lock.lockInterruptibly()必须放在try块中或者在调用lockInterruptibly()的方法外抛出InterruptedException。

lock()
两个线程都使用lock获取锁,如果线程A获取到了锁,线程B只能等待,若对线程B调用interrupt()方法,不会抛出异常信息不能中断线程B的等待过程。

Lock lock = ...;
lock.lock();
try{
    //处理任务
}catch(Exception ex){
     
}finally{
    lock.unlock();   //释放锁
}

lockInterruptibly()
两个线程都使用lockInterruptibly获取锁,如果线程A获取到了锁,线程B只能等待,对线程B调用interrupt()方法,能够中断线程B的等待过程。

Lock lock = ...;
lock.lockInterruptibly();
try{
    //处理任务
}catch(Exception ex){
     
}finally{
    lock.unlock();   //释放锁
}

【精选】【并发编程】ReentrantLock的lockInterruptibly()方法源码分析-CSDN博客

4)tryLock()--获取锁超时--可中断

加锁时调用tryLock()方法会返回获取锁的结果,可通过返回的结果判断要执行的逻辑。

带参数的tryLock方法可以传进去等待的时间 例如:lock.tryLock(1, TimeUnit.SECONDS); 获取锁失败会重试,1s后依然没获得锁才会走失败的逻辑

通过tryLock()获取到锁的线程可以被中断且被中断会抛出被中断异常。

tryLock()

使用lock获取锁,如果线程A获取到了锁,线程A返回true,线程B直接返回false。可以传入时间参数,表示拿不到锁等待一段时间,这段时间内还是拿不到就返回false。

Lock lock = ...;
if(lock.tryLock()) {
     try{
         //处理任务
     }catch(Exception ex){
         
     }finally{
         lock.unlock();   //释放锁
     } 
}else {
    //如果不能获取锁,则直接做其他事情
}
5)条件变量的使用

ReentrantLock 中的条件变量功能,类似于 synchronized 的同步监视器即锁的 wait()、notify()、notifyAll()。我们可以使用Reentrantlock 锁,配合 Condition 对象上的 await()和 signal()或 signalAll()方法,来实现线程间协作。与synchronized的wait和notify不同之处在于,ReentrantLock中的条件变量可以有多个,可以实现更精细的控制线程。

Condition中常用的方法API有如下这些:

Java面试题:如何使用ReentrantLock的条件变量,让多个线程顺序执行? - 知乎

2.ReentrantReadWriteLock(可重入读写锁)

java.util.concurrent.locks包中ReentrantReadWriteLock类的公平锁和非公平锁:ReentrantReadWriteLock是一个读写锁,它也提供了公平锁和非公平锁两种模式。在创建ReentrantReadWriteLock对象时,可以通过构造函数传入一个布尔类型的fair参数来指定锁的模式。如果fair为true,则创建公平锁;如果fair为false,则创建非公平锁。

3.StampedLock(该锁不允许重入

StampedLock - 简书

java.util.concurrent.locks包中StampedLock类乐观锁和悲观锁:StampedLock是Java 8中新增的一种锁实现类,它提供了乐观锁和悲观锁两种模式。在使用StampedLock时,可以通过调用tryOptimisticRead()方法来获取乐观锁,或者通过调用readLock()方法来获取悲观锁。乐观锁是一种无锁的机制,它不会阻塞线程,但是需要通过validate()方法来检查锁是否仍然有效。

4.synchronized(非公平锁

关键字synchronized的非公平锁:Synchronized是Java中内置的锁机制,它采用非公平锁模式。在使用Synchronized时,如果多个线程同时请求锁,则会根据系统的调度算法来决定哪个线程获取锁。

5.synchronized是公平锁还是非公平锁?

为什么说 Synchronized 是 java 非公平锁? - 知乎

非公平,新来的线程有可能立即获得监视器,而在等待区中等候已久的线程可能再次等待,不过这种抢占的方式可以预防饥饿。

使用synchronized关键字实现非公平锁机制时,新来的线程有机会在等待较短的时间内获得锁,而等待时间较长的线程可能需要再次等待。虽然这可能会导致某些线程等待时间较长,但是由于新来的线程可以在较短的时间内获得锁,从而避免了某些线程长时间无法获取锁的情况。这种情况下,所有线程都有机会获取锁,而不会出现某些线程长时间无法获取锁的情况,从而预防了饥饿的发生。

饥饿是指某些线程由于一些原因,如优先级太低、锁分配不公平等,长时间无法获取到锁或资源,从而无法执行或执行效率低下。使用非公平锁机制可以避免某些线程长时间无法获取锁的情况,从而预防了饥饿的发生。

需要注意的是,非公平锁机制并不能完全避免饥饿的发生,某些线程仍然可能由于等待时间过长而无法获取锁或资源,从而发生饥饿。为了避免饥饿的发生,可以采用一些措施,如优先级调度、公平锁机制等。

6.synchronized底层源码如何实现

synchronized 常见面试题总结_synchronized 面试题_路上阡陌的博客-CSDN博客

6.1特性

原子性

所谓原子性就是指一个操作或者多个操作,要么全部执行,要么全部不执行。

在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。但是像i++、i+=1等操作字符就不是原子性的,它们是分成读取、计算、赋值几步操作,原值在这些步骤还没完成时就可能已经被赋值了,那么最后赋值写入的数据就是脏数据,无法保证原子性。

被synchronized修饰的方法或者代码块是原子的,因为在执行同步代码之前必须先获取锁,直到执行完才能释放,确保了线程互斥的访问同步代码,这中间的过程无法被中断(除了已经废弃的stop()方法),即保证了原子性。(注意!面试时经常会问比较synchronized和volatile,它们俩特性上最大的区别就在于原子性,volatile不具备原子性。)

可见性

保证共享变量的修改对其他线程能够及时可见。

  1. 线程解锁前,必须把共享变量的最新值刷新到主内存中。
  2. 线程加锁前,将清空工作内存中共享变量的值,从主内存中重新读取最新的值。
  3. volatile 的可见性都是通过内存屏障(Memnory Barrier)来实现的。
  4. synchronized 的可见性靠操作系统内核的Mutex Lock(互斥锁)实现,相当于JMM(Java Memory Model即Java内存模型,并不真实存在,它描述的是一种和多线程相关的一组规范)中的 lock()、unlock()。
有序性

1.happens-before

前一个线程对一个监视器(锁)的释放操作先(happens-before)于后一个线程对该监视器(锁)的获取操作。

2.as if serial

为什么synchronized无法禁止指令重排,却能保证有序性?

因为在一个线程内部,它不管怎么指令重排,他都是as if serial(好像串行的,也就是说单线程内部的程序指令即使被重排序了,其运行结果和串行运行的结果也是一样的,是类似串行的语义。

  • 当线程运行到同步块时,会加锁,其他线程无法获得锁,也就是说此时同步块内的方法是单线程的,根据as if serial,可以认为他是有序的。
  • 而指令重排序导致线程不安全是多线程运行的时候,不是单线程运行的时候,因此多线程运行时禁止指令重排序也可以实现有序性,这就是volatile。

synchronized和volatile都具有有序性,多核cpu环境下Java允许对指令进行重排 (指令重排会提高运行效率),但是指令重排并不会影响单线程的顺序,它影响的是多线程并发执行的顺序性。

可重入性

Synchronized和ReentrantLock都是可重入锁。当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁。通俗一点讲就是说一个线程拥有了锁仍然还可以重复申请同一个锁。可重入锁,表示该锁能够支持一个线程对资源的重复加锁

7.


三、死锁-及其解决方式

1.什么是死锁

多个并发进程(线程)因争夺资源而产生的相互循环等待的现象。死锁会浪费大量系统资源,甚至导致系统崩溃。

2.本质原因

1)、系统资源有限。
2)、进程(线程)推进顺序不合理。

3.产生死锁的4个必要条件

1、互斥条件

在一段时间内某资源只能被某一个进程(线程)单独占有。此时若有其他进程(线程)请求该资源,则请求进程(线程)只能等待。

2、不可剥夺条件(不能被动释放)

进程(线程)所获得的资源在未使用完毕之前,不能被其他进程(线程)强行夺走,即只能由获得该资源的进程(线程)自己来主动释放。

3、请求与保持/占有并等待条件(不主动释放)

进程(线程)已经保持了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程(线程)占有且不被释放,此时请求进程(线程)被阻塞,但对自己已获得的资源也保持不放。

4、循环等待条件

存在对某种或者某些资源的循环等待链,链中每一个进程(线程)已获得的资源同时被链中下一个进程(线程)所请求。即存在一个处于等待状态的进程(线程)集合{Pl, P2, …, Pn},其中Pi等 待的资源被P(i+1)占有(i=0, 1, …, n-1),Pn等待的资源被P0占有。

只要上述条件之一不满足,就不会发生死锁。

4、处理死锁的方法

预防死锁:通过设置某些限制条件,去破坏产生死锁的四个必要条件中的一个或几个条件,来防止死锁的发生。
避免死锁:在资源的动态分配过程中,用某种方法去防止系统进入不安全状态,从而避免死锁的发生。

通过预防和避免的手段达到排除死锁的目的是很困难的。这需要较大的系统开销,而且不能充分利用资源。为此,一种简便的方法是系统为进程分配资源时,不采取任何限制性措施,但是提供了检测和解脱死锁的手段:能发现死锁并从死锁状态中恢复出来。因此,在实际的操作系统中往往采用死锁的检测与恢复方法来排除死锁

检测死锁:允许系统在运行过程中发生死锁,但设置检测机制及时检测死锁的发生。
解除死锁:当检测出死锁后,便采取措施将进程(线程)从死锁状态中解脱出来。

​​

A是解除死锁、       B是(在资源的动态分配过程中避免死锁、

C是(静态)预防死锁、       D是检测死锁

1.预防死锁(资源预先静态分配法、资源顺序分配法)

资源预先静态分配法可以预防死锁的发生,是一种常见的资源管理技术。它是指在程序运行之前,将计算机系统中的资源分配给程序,以便程序在运行时可以直接使用这些资源。这种方法可以提高程序的运行效率和稳定性,同时也可以避免资源竞争和冲突。

破坏“互斥”条件:

使资源可以同时访问而不是互斥访问。没法破坏,是资源本身的性质所引起的。

破坏“请求与保持/占有并等待”条件:

方法一:一次申请所需的全部资源,即 “ 一次性分配”。但是这种方式会严重降低资源的利用率,因为有些资源是在运行前期使用,而有些是在运行后期才使用的。

方法二:占有资源的进程(线程)若要申请新资源,必须主动释放已占有的资源。

破坏“不可抢占”条件:

破坏“不可抢占”条件就是允许对资源实行抢夺。 
方法一:如果一个进程请求当前被另一个进程占有的一个资源,则操作系统可以抢占另一个进程,要求它释放资源。只有在任意两个进程的优先级都不相同的条件下,方法一才能预防死锁。注意这和线程优先级不同,优先级最高的线程也不能抢占资源,只能等待持有锁的线程释放锁之后才去参与竞争锁,并且线程优先级高并不是绝对,只是概率更大。

方法二:资源分配管理程序为某进程分配新资源时,若有则分配;否则强制释放进程已占有的全部资源,并让进程进入等待资源状态,待资源充足后再唤醒它重新申请所需资源。

破坏“循环等待”条件(资源顺序分配法):

可釆用资源顺序分配法,将系统中的所有资源进行统一编号。必须按照顺序申请和释放资源,想要申请前面的资源必须先把该资源之后的资源(前提是已获得)全部释放,想要申请后面的资源必须先把该资源之前的资源全部申请

例如有 1、2、3、4、5, 5个资源

线程A按照1,2,3顺序访问,已占有1

线程B按照2,3,4顺序访问,已占有2

线程C按照34,5顺序访问,已占有3,4

线程D按照4,5顺序访问,未占有任何资源

线程E按照5顺序访问,已占有5

则线程A、B、C、D阻塞等待线程E先执行完,线程E执行完释放资源5,线程C获得全部所需资源后开始执行,执行完释放资源3,4,5,其它线程仍然按照资源顺序分配法申请资源。

缺点:这种按规定次序申请资源的方法,必然会给编程带来麻烦。

2.避免死锁(银行家算法)

1)加锁顺序:资源按照一定的顺序加锁。

 public static void main(String[] args) {
    Object locker1 = new Object();
    Object locker2 = new Object();
    Thread t1 = new Thread(() -> {
       synchronized (locker1){
           synchronized (locker2){
                   
           }
       }
    });
    t1.start();
    Thread t2 = new Thread(() -> {
       synchronized (locker1){
           synchronized (locker2){
               
           }
       }
    });
    t2.start();
}
//【这样就不会产生死锁了,当t1线程对locker1加锁了,t2线程也想对locker1加锁时,
//t2线程就需要阻塞等待,等t1线程执行完t2才能够获取锁.】

2)加锁时限:线程尝试获取锁的时候加上一定的时限,超过时限则放弃对该锁的请求,并释放自己占有的锁。
3)死锁检测(银行家算法:银行家算法的核心思想:在资源分配之前预先判断这次分配是否会导致系统进入不安全状态(是否存在安全序列),以此决定是否答应资源的分配请求。如果会进入不安全状态,就暂时不答应这种请求,让该进程先阻塞等待;银行家算法有一个前提:已知了可以让进程执行完毕释放资源还需要的全部资源。

银行家算法的图示

​​

银行家算法步骤

(注意:即便进程A在系统安全的前提下申请到了本次申请的资源,进程A可能仍然依赖其它资源,也就是进程A存在不能及时执行完毕并释放资源的可能。但是这里的前提是:已知了还需要的资源及其数量,不存在“分配后仍然依赖其它资源”的这个问题。)

1.进程A此次请求是否超过了当前资源池中的资源数量。

2.若超过,则系统处于不安全状态,不可以对进程A进行资源分配。

3.若不超过,接着判断分配给该进程A后剩余的资源,能不能满足其它进程中某个进程的最大资源需求。

4.若不能,则系统处于不安全状态,不可以对进程A进行资源分配。

5.如果存在某个进程B可被满足,则待该进程B执行完毕后回收已分配给它的资源(此时剩余资源数量增加),并把这个进程标记为可完成,然后继续判断队列中的其它进程是否可以组成安全序列。

6.若不存在安全序列,则系统处于不安全状态,不可以对进程A进行资源分配。

7.若存在安全序列(所有进程都可执行完毕),则系统处于安全状态。此时,可以对进程A进行资源分配。

​​


​​Process        Allocation        Need        Available

P2                  1354                 2356         1622       

P2请求1222                                                            

                       2576                1134          0400       

分配后,P2的Need还未满足不能执行完毕,且Available变为0400,

此时再进行安全性检查,发现 Available=(0,4,0,0) 不能满足任何一个进程,所以判定系统进入不安全状态,即不能分配给P2相应的Request(1,2,2,2)。

3.检测死锁(资源分配图简化法)

一般来说,由于操作系统有并发,共享以及随机性等特点,通过预防和避免的手段达到排除死锁的目的是很困难的。这需要较大的系统开销,而且不能充分利用资源。为此,一种简便的方法是系统为进程分配资源时,不采取任何限制性措施,但是提供了检测和解脱死锁的手段:能发现死锁并从死锁状态中恢复出来。因此,在实际的操作系统中往往采用死锁的检测与恢复方法来排除死锁

什么时候进行死锁的检测取决于死锁发生的频率,如果死锁发生的频率高,那么死锁检测的频率也要相应提高,这样可以避免更多的进程卷入死锁。为了减小死锁检测带来的系统开销,一般采取每隔一段时间进行一次死锁检测,或者在CPU的利用率降低到某一数值时,进行死锁的检测。

如果进程申请资源不能满足就立刻进行死锁检测,那么每当死锁形成时即能被发现,这和死锁避免的算法相近,只是系统的开销较大。

资源分配图简化法

当且仅当资源分配图是不可完全简化的时候,系统状态是死锁状态。

​​

​​

​​

​​

4.解除死锁(资源剥夺法、撤销进程法、进程回退法)

一旦检测出死锁,就应立即釆取相应的措施,以解除死锁。

资源剥夺法

挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源。

撤销进程法

强制撤销部分、甚至全部死锁进程并剥夺这些进程的资源。撤销的原则可以按进程优先级和撤销进程代价的高低进行。

进程回退法

让一(多)个进程回退到足以回避死锁的地步,进程回退时自愿释放资源而不是被剥夺。要求系统保持进程的历史信息,设置还原点。


四、CAS自旋

什么是CAS机制?-CSDN博客

我们先看一段代码:

启动两个线程,每个线程中让静态变量count循环累加100次。

​​

最终输出的count结果一定是200吗?因为这段代码是非线程安全的,所以最终的自增结果很可能会小于200。我们再加上synchronized同步锁,再来看一下。

​​

加了同步锁之后,count自增的操作变成了原子性操作,所以最终输出一定是count=200,代码实现了线程安全。虽然synchronized确保了线程安全,但是在某些情况下,这并不是一个最优的选择。关键在于性能问题。

尽管JAVA 1.6为synchronized做了优化,增加了从偏向锁到轻量级锁再到重量级锁的锁膨胀机制。但是在最终转变为重量级锁之后,性能仍然比较低,会让没有得到锁资源的线程进入BLOCKED状态,而后在争夺到锁资源后恢复为RUNNABLE状态,这个过程中涉及到操作系统用户模式和内核模式的转换,代价比较高。

所以面对这种情况,我们就可以使用java中的“原子操作类”:

所谓原子操作类,指的是java.util.concurrent.atomic包下,一系列以Atomic开头的包装类。如AtomicBoolean,AtomicUInteger,AtomicLong。它们分别用于Boolean,Integer,Long类型的原子性操作。

现在我们尝试使用AtomicInteger类:

​​

使用AtomicInteger之后,最终的输出结果同样可以保证是200。并且在某些情况下,代码的性能会比synchronized更好。

而Atomic操作类的底层正是用到了“CAS机制”:

CAS是英文单词Compare and Swap的缩写,翻译过来就是比较并替换。

CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。更新一个变量的时候,只有当变量的预期值A和内存地址V当中的实际值相同时,才会将内存地址V对应的值修改为B。

我们看一个例子:

1. 在内存地址V当中,存储着值为10的变量。

​​

2. 此时线程1想把变量的值增加1.对线程1来说,旧的预期值A=10,要修改的新值B=11.

​​

3. 在线程1要提交更新之前,另一个线程2抢先一步,把内存地址V中的变量值先更新成11。

​​

4. 线程1提交更新,会先进行A和地址V的实际值比较,发现A不等于V的实际值,提交失败。

​​

5. 线程1 重新获取内存地址V的当前值,并重新计算想要修改的值。此时对线程1来说,A=11,B=12。这个重新尝试的过程被称为自旋

​​

6. 这一次比较幸运,没有其他线程改变地址V的值。线程1进行比较,发现A和地址V的实际值是相等的。

​​

7. 线程1进行交换,把地址V的值替换为B,也就是12.

​​

从思想上来说,synchronized(当升级为重量级锁时)属于悲观锁,悲观的认为程序中的并发情况严重,所以严防死守,CAS属于轻量级的乐观锁,乐观地认为程序中的并发情况不那么严重,所以让线程不断去重试更新。

CAS的缺点:

1) CPU开销过大

在并发量比较高的情况下,如果许多线程反复尝试更新某一个变量,却又一直更新不成功,循环往复,会给CPU带来很到的压力。

2) 不能保证代码块的原子性

CAS机制所保证的知识一个变量的原子性操作,而不能保证整个代码块的原子性。比如需要保证3个变量共同进行原子性的更新,就不得不使用synchronized了。

3) ABA问题

这是CAS机制最大的问题所在。(后面有介绍)

什么是ABA问题:

假设内存中有一个值为A的变量,存储在地址V中。

​​

此时有三个线程想使用CAS的方式更新这个变量的值,每个线程的执行时间有略微偏差。线程1和线程2已经获取当前值,线程3还未获取当前值。

​​

接下来,线程1先一步执行成功,把当前值成功从A更新为B;同时线程2因为某种原因被阻塞住,没有做更新操作;线程3在线程1更新之后,获取了当前值B。

​​

在之后,线程2仍然处于阻塞状态,线程3继续执行,成功把当前值从B更新成了A。

​​

最后,线程2终于恢复了运行状态,由于阻塞之前已经获得了“当前值A”,并且经过compare检测,内存地址V中的实际值也是A,所以成功把变量值A更新成了B。

​​

看起来这个例子没啥问题,但如果结合实际,就可以发现它的问题所在。

我们假设一个提款机的例子。假设有一个遵循CAS原理的提款机,小灰有100元存款,要用这个提款机来提款50元。

​​

由于提款机硬件出了点问题,小灰的提款操作被同时提交了两次,开启了两个线程,两个线程都是获取当前值100元,要更新成50元。

理想情况下,应该一个线程更新成功,一个线程更新失败,小灰的存款值被扣一次

​​

线程1首先执行成功,把余额从100改成50.线程2因为某种原因阻塞。这时,小灰的妈妈刚好给小灰汇款50元。

​​

线程2仍然是阻塞状态,线程3执行成功,把余额从50改成了100。

​​

线程2恢复运行,由于阻塞之前获得了“当前值”100,并且经过compare检测,此时存款实际值也是100,所以会成功把变量值100更新成50。

​​

原本线程2应当提交失败,小灰的正确余额应该保持100元,结果由于ABA问题提交成功了

如何解决ABA问题:

怎么解决呢?加个版本号就可以了,利用版本号比较可以有效解决ABA问题。

真正要做到严谨的CAS机制,我们在compare阶段不仅要比较期望值A和地址V中的实际值,还要比较变量的版本号是否一致。

我们仍然以刚才的例子来说明,假设地址V中存储着变量值A,当前版本号是01。线程1获取了当前值A和版本号01,想要更新为B,但是被阻塞了。

​​

这时候,内存地址V中变量发生了多次改变,版本号提升为03,但是变量值仍然是A。

​​

随后线程1恢复运行,进行compare操作。经过比较,线程1所获得的值和地址的实际值都是A,但是版本号不相等,所以这一次更新失败。

​​

Java语言的CAS底层如何实现:

Java是通过Unsafe类提供的原子性方法,如compareAndSwapInt、compareAndSwapObject等方法来实现原子包装类的CAS机制的。

我们以AtomicInteger为例,看一下AtomicInteger当中常用的自增方法incrementAndGet()

public final int incrementAndGet() {
    for (;;) { //自旋
        int current = get(); //获取当前值
        int next = current + 1; //当前值+1,计算出目标值
        if (compareAndSet(current, next)) //进行CAS操作
            return next; //如果成功则跳出循环,如果失败则重复上述步骤
    }
}

private volatile int value; //volatile保证获取的当前值是内存中的最新值

public final int get() {
    return value;
}

这段代码是一个无限循环,也就是CAS的自旋,循环体中做了三件事:

1. 获取当前值

2. 当前值+1,计算出目标值

3. 进行CAS操作,如果成功则跳出循环,如果失败则重复上述步骤

4. 这里需要注意的重点是get方法,这个方法的作用是获取变量的当前值。如何保证获取的当前值是内存中的最新值?很简单,用volatile关键字来保证(保证线程间的可见性)。

我们接下来看一下compareAndSet(current, next)方法的实现:

​​

compareAndSet方法的实现很简单,只有一行代码。这里涉及到两个重要的变量,一个是unsafe,一个是valueOffset。

什么是Unsafe呢?Java语言不像C,C++那样可以直接访问底层操作系统,但是JVM为我们提供了一个后门,这个后门就是Unsafe。Unsafe为我们提供了硬件级别的原子操作

至于valueOffset长整型,是通过unsafe.objectFiledOffset方法得到,所代表的是AtomicInteger对象value成员变量在内存中的偏移量。我们可以简单的把valueOffset理解为value变量的内存地址

我们上面说过,CAS机制中使用了3个基本操作数:内存地址V,旧的预期值A,要修改的新值B。

而unsafe的compareAndSwapInt方法的参数包括了这三个基本元素:valueOffset参数代表了V,expect参数代表了A,update参数代表了B。

正是Unsafe的compareAndSwapInt等方法保证了Compare和Swap操作之间的原子性操作。


五、ThreadLocal

1.ThreadLocal即线程本地变量

ThreadLocal,即线程本地变量。如果你创建了一个ThreadLocal变量,那么访问这个变量的每个线程都会有这个变量的一个本地拷贝,多个线程操作这个变量的时候,实际是在操作自己本地内存里面的变量,从而起到线程隔离的作用,避免了并发场景下的线程安全问题。

//创建一个ThreadLocal变量
static ThreadLocal<String> localVariable = new ThreadLocal<>();

并发场景下,会存在多个线程同时修改一个共享变量的场景。这就可能会出现线性安全问题。为了解决线性安全问题,可以用加锁的方式,就是使用时间换空间的方式,比如使用synchronized 或者Lock(如Lock的实现类ReentrantLock。但是加锁可能会导致系统变慢。加锁示意图如下:

还有另外一种方案,就是使用空间换时间的方式,即使用ThreadLocal。ThreadLocal 是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和lock(这里的lock 指通过synchronized 或者Lock 等实现的锁) 是有本质的区别的:Synchronized用于线程间的数据共享,而ThreadLocal则用于线程间的数据隔离

  1. lock 的资源是多线程共享的,所以访问的时候需要加锁。
  2. ThreadLocal 不是多线程共享的,是一个线程的本地变量,每个线程都有一个副本,是不需要加锁的。
  3. lock 是通过时间换空间的做法。
  4. ThreadLocal 是典型的通过空间换时间的做法。
  5. 当然他们的使用场景也是不同的,关键看你的资源是需要多线程之间共享的还是单线程内部共享的。

2.ThreadLocalMap和HashMap区别

2.1区别

  • HashMap的数据结构是数组+链表+红黑树,ThreadLocalMap的数据结构仅仅是数组。
  • HashMap是通过链地址法解决hash冲突的问题,ThreadLocalMap是通过开放地址法来解决hash冲突的问题。
  • HashMap里面的Entry 内部类的引用都是强引用,ThreadLocalMap里面的Entry内部类中的key是弱引用,value是强引用。

2.2链地址法/拉链法--HashMap是通过链地址法解决hash冲突的问题

2.2.1链地址法/拉链法

基本思想是将所有哈希地址为i的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第i个单元中,因而查找、插入和删除主要在同义词链中进行。链地址法对于可能会造成很多哈希冲突的哈希函数来说,提供了绝不会出现找不到地址的保障。当然,这也增加了查找时需要遍历单链表的性能消耗

例如对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们可以用12作为除数,进行除留余数法,可得到下图结构,此时,就不存在哈希冲突换地址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题

2.2.2哈希相关概念补充
2.2.2.1 前言:

我们知道,数组的最大特点就是:寻址容易,插入和删除困难;而链表正好相反,寻址困难,而插入和删除操作容易。那么如果能够结合两者的优点,做出一种寻址、插入和删除操作同样快速容易的数据结构,那该有多好。这就是哈希表创建的基本思想,而实际上哈希表也实现了这样的一个“夙愿”,哈希表就是这样一个集快速查找、插入和删除操作于一身的数据结构

2.2.2.2 “hash冲突” 和 “关键字” 以及 “同义词”:

一般散列函数都面临着冲突的问题(不同的输入可能会散列成相同的输出)。两个不同的关键字(即哈希函数的入参),由于散列函数值相同,因而被映射到表同一位置上。该现象称为冲突(Collision)或碰撞。发生冲突的两个关键字称为该散列函数的同义词(Synonym)。

2.2.2.3 “散列表”

也叫哈希表(Hash Table),是根据关键码值(key-value)而直接进行访问的数据结构,也就是我们常用到的map。哈希表(Hash Table)是一种特殊的数据结构,它最大的特点就是可以快速实现查找、插入和删除

2.2.2.4 “哈希函数”

hash一般翻译做散列、杂凑,或音译为哈希,哈希函数也称为是散列函数,是Hash表的映射函数,它可以把任意长度的输入变换成固定长度的输出,该输出就是哈希值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出(称为哈希冲突),所以不可能从散列值来确定唯一的输入值
简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。使用Hash算法可以提高存储空间的利用率,可以提高数据的查询效率,也可以做数字签名 (数字签名是非对称密钥加密技术 与 数字摘要技术的应用)来保障数据传递的安全性。所以Hash算法被广泛地应用在互联网应用中。 Hash算法虽然被称为算法,但实际上更像是一 种思想。Hash算法没有固定的公式,只要符合散列思想的算法都可以被称为Hash算法(SHA1,SHA2,MD5)

2.3开放地址法--ThreadLocalMap是通过开放地址法来解决hash冲突的问题

基本思想是一旦发生了哈希冲突,就去寻找下一个空的散列地址(这非常重要,源码都是根据这个特性,必须理解这里才能往下走),只要散列表足够大,空的散列地址总能找到,并将记录存入。

比如说,我们的关键字集合为{12,33,4,5,15,25},表长为10。 我们用散列函数f(key) = key mod l0。 当计算前S个数{12,33,4,5}时,都是没有冲突的散列地址,直接存入(蓝色代表为空的,可以存放数据):

计算key = 15时,发现f(15) = 5,此时就与5所在的位置冲突。于是我们应用上面的公式f(15) = (f(15)+1) mod 10 =6。于是将15存入下标为6的位置。这其实就是房子被人买了于是买下一间的作法:

2.4链地址法和开放地址法的优缺点

开放地址法:

  • 容易产生堆积问题,不适于大规模的数据存储。
  • 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
  • 当删除的元素是多个冲突元素中的一个时,需要对后面的元素作处理,实现较复杂。

链地址法:

  • 处理冲突简单,且无堆积现象,平均查找长度短。
  • 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
  • 删除结点的操作易于实现,只要简单地删去链表上相应的结点即可。
  • 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。

ThreadLocalMap采用开放地址法原因:

JDK 中大多数的类都是采用了链地址法来解决hash 冲突,为什么ThreadLocalMap 采用开放地址法来解决哈希冲突呢?

  • ThreadLocal 中使用了斐波那契散列法,来保证哈希表的离散度。ThreadLocal 中看到一个属性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一个神奇的数字,让哈希码能均匀的分布在2的N次方的数组里, 即 Entry[] table,关于这个神奇的数字google 有很多解析,这里就不重复说了。
  • ThreadLocal 往往存放的数据量不会特别大(而且key 是弱引用会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也低。

3.ThreadLocal

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值