3、关键字详解

厨子大约 13 分钟Java 高并发原创面试题关键字程序厨

3.关键字详解

Volatile

3.0 并发编程的三个概念

原子性:要干就干完,要不干就不干

有序性:即程序执行的顺序按照代码的先后顺序执行。

Java内存模型中的有序性可以总结为:如果在本线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的

可见性:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

3.1 共享变量的可见性

JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

对于普通的共享变量来讲,线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile。

3.2 volatile的特性

volatile 保证可见性,有序性,但是不能保证原子性

可见性

volatile 关键字会将修改后的变量强制刷新到主内存中

然后使其他本地内存中的共享变量拷贝副本失效。其他线程需要获取该值时,则只能从主内存中获取,进而保证了可见性。

有序性

重排序是为了优化程序的性能,但是重排序后不会影响程序的执行结果

重排序的准则是这样。

(1)重排序操作不会对存在数据依赖关系的操作进行重排序。

a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以第二个应该发生在第一个的后面。

(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

单线程下如果使用如果在满足两个原则的情况下进行重排序是没有问题的,但是在多线程的情况下则会出现问题。

使用volatile关键字修饰共享变量,便可以禁止这种重排序,修饰共享变量时,则会在指令序列中插入内存屏障来禁止特定类型的重排序。主要是根据以下原则。

a.执行到 volatile 变量的读操作或者写操作时,在其前面的操作肯定全部已经进行,并且对后面的操作可见,在其后面的操作肯定还没有进行

b.不能将 volatile 前面的语句放到后面执行,也不能将后面的放到前面执行。

也就是执行到volatile时,前面所有的语句都执行完了,后面的所有语句都未执行,且前面的结果对后面可见。

3.3 volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

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

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

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

推荐阅读:https://www.cnblogs.com/dolphin0520/p/3920373.htmlopen in new window

3.4 为什么volatile不支持原子性

1.线程读取i

2.temp = i + 1

3.i = temp 当 i=5 的时候A,B两个线程同时读入了 i 的值, 然后A线程执行了 temp = i + 1的操作, 要注意,此时的 i 的值还没有变化,然后B线程也执行了 temp = i + 1的操作,注意,此时A,B两个线程保存的 i 的值都是5,temp 的值都是6, 然后A线程执行了 i = temp (6)的操作,此时i的值会立即刷新到主存并通知其他线程保存的 i 值失效, 此时B线程需要重新读取 i 的值那么此时B线程保存的 i 就是6,同时B线程保存的 temp 还仍然是6, 然后B线程执行 i=temp (6),所以导致了计算结果比预期少了1

3.5 Happen-Before规则

  • 一个程序内保证语义的串行性

  • volatile 变量的写先于读发生

  • 加锁先于解锁

  • 传递性

  • Start()第一个执行

  • 终结最后发生

  • 中断先于被中断

synchronized

3.6 synchronized 底层原理

几种用法

  • 修饰类

  • 修饰实例方法

  • 修饰代码块

锁是加在对象上的,无论是类对象还是实例对象,他们都是由对象头,实例变量,填充数据三部分组成

synchronized使用的锁对象是存储在Java对象头里的,其主要结构是由Mark Word 和 Class Metadata Address 组成,然后Mark Word 的话存储着对象的信息,HashCode、分代年龄、锁标记等。

Mark Word 的存储状态

Mark Word 是一个非固定的数据结构,这是为了存储更多的数据,然后他会根据对象的状态来复用自己的空间。

synchronized 是一个重量级锁,所以其标志位是 10,每个对象都与一个 monitor 相连,然后他可以随着对象创建或销毁,然后也可以随着对象加锁后而被创建。

monitor是由,monitorObject 实现的,然后他里面的几个重要的值我们来进行解释一下

  • count用来记录线程进入加锁代码的次数,

  • owner记录当前持有锁的线程,即持有ObjectMonitor对象的线程。(目前被谁加锁了)

  • EntryList是想要持有锁的线程的集合。(还有谁加锁了)

  • WaitSet 是加锁对象调用wait()方法后,等待被唤醒的线程的集合。(谁加了之后,释放了,然后等待被唤醒)

我个人是这样理解的,这个 monitor 就相当于是对象的管家,用来记录该对象被谁用了,然后后面还有谁想用。

另外需要注意的是 wait、notify、notifyAll 是synchronized 专有的,synchronized是可重入锁,再次获取锁时不会生阻塞,这也就是那个 count ++ 的原因

推荐阅读:https://zhuanlan.zhihu.com/p/377423211open in new window

3.7 Synchronized 锁升级

无锁状态

偏向锁:因为大部分情况是不存在锁竞争的,大部分还是同一线程获取同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁升级,某个线程获取对象的锁的时候,先查看对象的 threadid 是否和自己的id 相同,相同情况下说明自己拥有该锁,则无需重新通过CAS加锁,如果不一致,则查看锁住对象的那个线程是否还存活,如果不存活,则将对象设置成可竞争状态,其他线程可以来竞争锁。如果不存活,则查看该线程的祯栈来观察该线程是否还需要继续持有锁,如果不需要则,当前线程获取该锁,如果需要继续持有则升级成轻量级锁

轻量级锁:轻量级锁适用于请求锁的线程不多,然后持有锁时间不长的情况,因为线程等待需要从,用户态转换到内核态,如果出现刚调入内核态等待,然后锁就释放了,这一来一回造成的开销是很大的,所以就让线程进行自旋,等待锁的释放。

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间 (称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord )的地址,如果此时有别的线程也来获取锁的话,发现已经被线程 1 给更换了,那么则修改失败,自旋等待线程 1 释放锁,等待若干时间之后,发现还没有释放锁,则升级为重量级锁。

重量级锁:加锁后,如果其他线程想要获取锁,则会被堵塞。

注意锁不能降级,但是偏向锁可以被重置为无锁,

锁粗化

就是将多个频繁加锁解锁的小东西合成一个大东西,给锁起来,好比很多需要加锁的小箱子,给放到大箱子里进行加锁

锁消除

去除不可能存在共享资源竞争的锁

3.8 ReentrantLock底层原理

重入锁可以完全替代synchronized ,我们将其理解为锁的一种即可,然后可重入锁的一些特性,我们来对其进行改造,我们知道 synchronized 需要我们手动加锁,手动解锁。

另外当synchronized 需要请求锁的时候,有两种可能,要么获得该锁,要么继续等待。但是可重入锁有第三种情况,中断等待,或者到时放弃等待,trylock()。

与可重入锁配合的关键字 await 和 signal(),一个进入等待状态,并释放锁,signal 将其唤醒。

底层原理就是 CAS 和 AQS

假设我们一个线程获取锁,发现该锁已经被其他线程占用,则会wait,加入到AQS中,

然后我们再说一下公平锁和非公平锁,

1. 非公平锁,如果有其他线程尝试lock(),有可能被其他刚好申请锁的线程抢占。
2. 公平锁,只有在CLH队列头的线程才可以获取锁,新来的线程只能插入到队尾。

默认是非公平锁,因为这样可以避免维护一个有序队列。

通过大量CAS保证线程安全

如果通过CAS修改了值,则说明该线程获得了锁,然后一个线程也可以重复加锁,每加一次执行 state++(也就是可重入)。如果没有获得锁,则会加入AQS队列,通过自旋和CAS保证能够进入队列。写入队列之后,则需要挂起当前线程。

释放锁时 state—,直至为0之后,才释放该锁。

我们可以通过这个例子来进行解释,村里有一个口井,然后每个人都想在井里打水,但是这个井每次只能有一个打水,所以如果该井被使用的话,新的则需到队伍后面排队(公平锁),然后如果这个人打完了水(释放锁),有的人想利用换人的间隙,去抢打水的位置,(非公平锁),另外就是,如果某个人想连续打为 5 桶水,则需要占用 5 次打水的位置,当他打完 5 次之后,别人才能打(可重入)。

推荐阅读:https://www.cnblogs.com/heqiyoujing/p/11145146.htmlopen in new window

3.9 可重入锁 和 synchronized 的区别。

  • 可重入锁可以放弃等待,可以中断等待锁

  • synchronized 是基于JVM的,由操作系统实现,可重入锁是jdk实现

  • 可重入锁更加灵活,粒度小一些

  • 可重入锁可以通过Condition 来实现分组唤醒,不像syn 要么唤醒一个,要么全部唤醒

3.10 锁优化

无锁状态

偏向锁

因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁

轻量级锁

重量级锁

无锁

CAS就是无锁状态,乐观锁,默认是没有人占用我们的资源,通过比较来修改值,看起来比较复杂,但是这样不会出现死锁。无锁情况都是基于CAS原理

AtomicInteger

实现数字的原子性+/-,和 Redis 里的 INCR/DECR 一样,但是会出现ABA问题,因为比如我们给他 20 块钱,他又给花完了,让我们误以为还没有给他钱。

时间戳解决ABA问题

AtomicStampedReference通过时间戳来解决ABA问题,出现ABA问题的原因就是,对象在修改过程中丢失了状态信息,所以我们可以引入时间戳的,对象值和时间戳都必须要满足期望值,写入才会成功。

无锁数组

AtomicIntegerArray数组无锁

无锁的概念是通过 Atomic 包来进行的,主要通过CAS

减少锁持有的时间

尽可能减少持有锁的时间,等完全思考好之后再进行写,

减少锁粒度