一、先来看下面的程序
两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?
package com.gaoxin.courrent;
public class Demo01 {
public static int count = 0 ;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
count--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count的最后结果是:"+count);
}
}
最后结果是: count的最后结果是:1517
问题分析:
将上面的程序都跑几次,会发现,最后结果可以为正,也可以为负,也有可能为0。为什么会这样呢?
因为 Java 中对静态变量的自增,自减并不是原子操作,要彻底理解,必须从字节码来进行分析
例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量
而对应 i-- 也是类似:
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自减
putstatic i // 将修改后的值存入静态变量i
而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和工作内存中进行数据交换:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PBJ2w3Ud-1649427756208)(D:\desk\csnd文章\img\1649425248594.png)]
如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题。但多线程下这 8 行代码可能交错运行:
出现负数的情况:
二、在java中如何解决由于多线程并行导致的数据安全性问题?
思考如何保证线程并行的数据安全性
我们可以思考一下,问题的本质在于共享数据存在并发访问。如果我们能够有一种方法使得线程的并行变成串行,
那是不是就不存在这个问题呢?
按照大家已有的知识,最先想到的应该就是锁吧。 毕竟这个场景并不陌生,我们在和数据库打交道的时候,
就了解过悲观锁、乐观锁的概念。 什么是锁?它是处理并发的一种同步手段,而如果需要达到前面我们说的一个目的,那么这个锁一定需要实现互斥的特性。
Java 提供的加锁方法就是 Synchroinzed 关键字。
三、synchronized 的基本认识
在多线程并发编程中 synchronized 一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着 Java SE 1.6 对synchronized 进行了各种优化之后,有些情况下它就并不那么重,Java SE 1.6 中为了减少获得锁和释放锁带来的性能消耗而引入的偏向锁和轻量级锁。这块在后续我们会慢慢展开 。
1、synchronized 的基本语法
synchronized 有三种方式来加锁,分别是
- 修饰实例方法,作用于当前实例加锁,进入同步代码前
要获得当前实例的锁 - 静态方法,作用于当前类对象加锁,进入同步代码前要
获得当前类对象的锁 - 修饰代码块,指定加锁对象,对给定对象加锁,进入同
步代码库前要获得给定对象的锁。
不同的修饰类型,代表锁的控制粒度
2、思考锁是如何存储的
可以思考一下,要实现多线程的互斥特性,那这把锁需要
哪些因素?
- 锁需要有一个东西来表示,比如获得锁是什么状态、无
锁状态是什么状态 - 这个状态需要对多个线程共享
那么我们来分析,synchronized 锁是如何存储的呢?观察synchronized 的整个语法发现,synchronized(lock)是基于lock 这个对象的生命周期来控制锁粒度的,那是不是锁的存储和这个 lock 对象有关系呢?
于是我们以对象在 jvm 内存中是如何存储作为切入点,去看看对象里面有什么特性能够实现锁 对象在内存中的布局 在 Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Header)、实例数据(Instance Data)、对齐填充(Padding)
3、为什么任何对象都可以实现锁
- 首先,Java 中的每个对象都派生自 Object 类,而每个Java Object 在 JVM 内部都有一个 native 的 C++对象
oop/oopDesc 进行对应。 - 线程在获取锁的时候,实际上就是获得一个监视器对象(monitor) ,monitor 可以认为是一个同步对象,所有的Java 对象是天生携带 monitor。在 hotspot 源码的 markOop.hpp 文件中,可以看到下面这段代码。
多个线程访问同步代码块时,相当于去争抢对象监视器修改对象中的锁标识,上面的代码中ObjectMonitor这个
对象和线程争抢锁的逻辑有密切的关系 。
四、锁的优化和升级
我们使用多线程的目的就在于并发地去处理相关业务问题,提高性能。但是通过加锁的方式,将并发改成了串行,使性能下降了,违背了我们当初用多线程的初衷。那么,现在我们想要的是:既要实现数据的安全性,又要保准性能。在这一要求下,在1.6之后便对锁进行了一定的优化。接下来,让我们来继续分析锁的优化和升级过程:
hotspot 虚拟机的作者经过调查发现,大部分情况下,加锁的代码不仅仅不存在多线程竞争,而且总是由同一个线程多次获得。所以基于这样一个概率,是的 synchronized 在JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized 中,锁存在四种状态 分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。
1、偏向锁
大部分情况下,锁不仅仅不存在多线程竞争,而是总是由同一个线程多次获得,为了让线程获取锁的代价更低就引入了偏向锁的概念。怎么理解偏向锁呢? 当一个线程访问加了同步锁的代码块时,会在对象头中存储当前线程的 ID,后续这个线程进入和退出这段加了同步锁的代码块时,不需要再次加锁和释放锁。而是直接比较对象头里面是否存储了指向当前线程的偏向锁。如果相等表示偏向锁是偏向于当前线程的,就不需要再尝试获得锁了。
如下程序:
static final Object obj = new Object();
public static void m1() {
synchronized( obj ) {
// 同步块 A
m2();
}
}
public static void m2() {
synchronized( obj ) {
// 同步块 B
m3();
}
}
public static void m3() {
synchronized( obj ) {
// 同步块 C
}
}
我们看一下对象头格式:
偏向锁的获取和撤销逻辑
-
首先获取锁 对象的 Markword,判断是否处于可偏向状
态。(biased
_lock=1、且 ThreadId 为空) -
如果是可偏向状态,则通过 CAS 操作,把当前线程的 ID
写入到 MarkWord
a) 如果 cas 成功,那么 markword 就会变成这样。表示已经获得了锁对象的偏向锁,接着执行同步代码块 。
b) 如果 cas 失败,说明有其他线程已经获得了偏向锁,这种情况说明当前锁存在竞争,需要撤销已获得偏向锁的线程,并且把它持有的锁升级为轻量级锁(这个操作需要等到全局安全点,也就是没有线程在执行字节码)才能执行 。 -
如果是已偏向状态,需要检查 markword 中存储的ThreadID 是否等于当前线程的 ThreadID
a) 如果相等,不需要再次获得锁,可直接执行同步代码块
b) 如果不相等,说明当前锁偏向于其他线程,需要撤销偏向锁并升级到轻量级锁 。
偏向锁的撤销
偏向锁的撤销并不是把对象恢复到无锁可偏向状态(因为偏向锁并不存在锁释放的概念),而是在获取偏向锁的过程中,发现 cas 失败也就是存在线程竞争时,直接把被偏向的锁对象升级到被加了轻量级锁的状态。
对原持有偏向锁的线程进行撤销时,原获得偏向锁的线程
有两种情况:
- 原获得偏向锁的线程如果已经退出了临界区,也就是同步代码块执行完了,那么这个时候会把对象头设置成无锁状态并且争抢锁的线程可以基于 CAS 重新偏向但前线程 。
- 如果原获得偏向锁的线程的同步代码块还没执行完,处于临界区之内,这个时候会把原获得偏向锁的线程升级为轻量级锁后继续执行同步代码块 。
2、轻量级锁
轻量级锁的加锁和解锁逻辑
锁升级为轻量级锁之后,对象的 Markword 也会进行相应的的变化。升级为轻量级锁的过程:
- 线程在自己的栈桢中创建锁记录 LockRecord。
- 将锁对象的对象头中的MarkWord复制到线程的刚刚创
建的锁记录中。 - 将锁记录中的 Owner 指针指向锁对象。
- 将锁对象的对象头的 MarkWord替换为指向锁记录的指
针。
自旋锁
轻量级锁在加锁过程中,用到了自旋锁 所谓自旋,就是指当有另外一个线程来竞争锁时,这个线程会在原地循环等待,而不是把该线程给阻塞,直到那个获得锁的线程释放锁之后,这个线程就可以马上获得锁的。 注意,锁在原地循环的时候,是会消耗 cpu 的,就相当于在执行一个啥也没有的 for 循环。 所以,轻量级锁适用于那些同步代码块执行的很快的场景,这样,线程原地等待很短的时间就能够获得锁了。 自旋锁的使用,其实也是有一定的概率背景,在大部分同步代码块执行的时间都是很短的。所以通过看似无异议的循环反而能提升锁的性能。 但是自旋必须要有一定的条件控制,否则如果一个线程执行同步代码块的时间很长,那么这个线程不断的循环反而
会消耗 CPU 资源。默认情况下自旋的次数是 10 次, 可以通过 preBlockSpin 来修改 。
在 JDK1.6 之后,引入了自适应自旋锁,自适应意味着自旋的次数不是固定不变的,而是根据前一次在同一个锁上自旋的时间以及锁的拥有者的状态来决定。 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。
轻量级锁的解锁
轻量级锁的锁释放逻辑其实就是获得锁的逆向逻辑,通过CAS 操作把线程栈帧中的 LockRecord 替换回到锁对象的MarkWord 中,如果成功表示没有竞争。如果失败,表示当前锁存在竞争,那么轻量级锁就会膨胀成为重量级锁
3、重量级锁
Monitor 原理
Monitor 被翻译为监视器或管程
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的
Mark Word 中就被设置指向 Monitor 对象的指针
Monitor 结构如下:
。刚开始 Monitor 中 Owner 为 null
。当 Thread-2 执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2,Monitor中只能有一
个 Owner
。在 Thread-2 上锁的过程中,如果 Thread-3,Thread-4,Thread-5 也来执行 synchronized(obj),就会进入
EntryList BLOCKED
。Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争的时是非公平的
。图中 WaitSet 中的 Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲
wait-notify 时会分析
注意
1、synchronized 必须是进入同一个对象的 monitor 才有上述的效果
2、不加 synchronized 的对象不会关联监视器,不遵从以上规则
重量级锁的基本原理
当轻量级锁膨胀到重量级锁之后,意味着线程只能被挂起阻塞来等待被唤醒了。
加 了 同 步 代 码 块 以 后 , 在 字 节 码 中 会 看 到 一 个monitorenter 和 monitorexit。
每一个 JAVA 对象都会与一个监视器 monitor 关联,我们可以把它理解成为一把锁,当一个线程想要执行一段被
synchronized 修饰的同步方法或者代码块时,该线程得先获取到 synchronized 修饰的对象对应的 monitor。
monitorenter 表示去获得一个对象监视器。monitorexit 表示释放 monitor 监视器的所有权,使得其他被阻塞的线程可以尝试去获得这个监视器 monitor 依赖操作系统的 MutexLock(互斥锁)来实现的, 线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能 。
**重量级锁的加锁的基本流程 **
任意线程对 Object(Object 由 synchronized 保护)的访问,首先要获得 Object 的监视器。如果获取失败,线程进入同步队列,线程状态变为 BLOCKED。当访问 Object 的前驱(获得了锁的线程)释放了锁,则该释放操作唤醒阻塞在同步队列中的线程,使其重新尝试对监视器的获取。