深入理解java锁

前言

本文章是作者学习的笔记,仅供参考
在我们聊锁之前,那肯定不得不先提到我们并发编程的基础-----------------------------------------------------------

CAS机制

CAS机制是一种数据更新的方式。在具体讲什么是CAS机制之前,先简单介绍一下在多线程环境下,对共享变量进行数据更新的两种模式:乐观锁和悲观锁
(1)悲观锁:在更新数据的时候,认为总会有其他线程来“捣乱”,修改数据,所以我要把资源上锁,这样其他线程想要访问这个资源的时候就会堵塞
(2)乐观锁:和悲观锁相反,我认为不会有其他线程来捣乱,我就不给资源上锁,但是我会在更新数据前判断一下资源是否被其他线程改过,具体实现CAS请看下面的图解。
image-20210917131244228
ABA问题:

你女朋友在分手状态时把你绿了,后面他们又分了,然后女朋友和你复合,你复合后不知道他这段时间和别人好了。这就是ABA

如何解决ABA?:

给女朋友身体标记一下,这样如果绿了,你就能知道你被绿了(我邪恶了~)



1.锁是什么?

相信各位初学者第一次认识锁都是在学习线程的时候,有部分小白没啥准备就去面试的时候,面试官让他聊聊Java锁,他自信张口就是一句:“锁就是synchronized”。大哥,人家synchronized是个关键字,要说也只能说是悲观锁或内置锁的实现,怎么到你这变成锁就是synchronized了呢,锁的种类有很多的好不好!哈哈,开个玩笑。

1-1.并发编程的特性

讲并发编程之前,必须要先简述并发编程三大特性:

(1)原子性:一次或多次操作,要么所有操作全部执行不被干扰,要么全都不执行。
(2)可见性:当一个线程对共享变量进行修改,那么其他线程要可以立刻看到修改后的值。
(3)有序性:代码的执行顺序会被编译优化,最终未必就是编写代码时候的顺序。

1-1-1.synchronized关键字

synchronized相信大家都看过或者用过,synchronized是Java中的关键字,synchronized可以保证方法或者代码块在运行时,同一时刻只有一个方法可以进入到临界区,同时它还可以保证共享变量的内存可见性,Java中每一个对象都可以作为锁,这是synchronized实现同步的基础。
synchronized 关键字可以保证原子性,也可以保证可见性

🧡它修饰的对象有以下几种:

  1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
  2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
  3. 修饰一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
  4. 修饰一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
    *synchronized使用详解 *

🧡synchronized的实现过程:
(1)Java代码中:synchronized关键字
(2)编译过程,字节码中:monitorenter(执行开始)moniterexit(执行完)
(3)JVM执行过程中:锁升级环节
(4)cpu中:lock cmpechg

1-1-2.volatile关键字

使用volatile 关键字,可以强制的从公共内存中读取值。使用volatile关键字增加了实例变量在多个线程之间的可见性。但是volatile关键字的缺点是不支持原子性。
volatile 关键字可以禁止指令进行重排序优化,也可以增加可见性
🧡它修饰的对象是 变量

1-1-3. 关键字比较

1)volatile 是线程同步的轻量级实现,所以volatile性能肯定比synchronized要好,但是volatile只能修饰变量,而synchronized可以修饰方法,代码块。

2)多线程访问volatile 不会发生阻塞,而synchronized 会发生阻塞。

3)volatile 能保证数据的可见性,但不能保证原子性,而 synchronized可以保证原子性,也可以保证可见性,因为它会将私有内存和公共内存中的数据做同步。

1-1-4. ReentrantLock 可重入锁

在谈ReentrantLock 前,我们先来了解一下另一种锁分类:内置锁和显式锁,我们前面讲到的synchronized就是内置锁,很多小伙伴觉得synchronized已经很好用了啊,为什么还要搞一个什么显式锁呢?接下来就通过ReentrantLock来带大家了解:

有些事情内置锁是做不了的,我们只能搞一个新玩意ReentrantLock来做,比如:

(1)可定时:要加个超时等待时间,超时了就停止获取锁,这样就不会无限等待。

RenentrantLock.tryLock(long timeout, TimeUnit unit);

(2)可中断:通过外部线程发起中断信号,中断某些耗时的线程,唤醒等待线程。

RenentrantLock.lockInterruptibly();

(3)条件队列:
线程在获取锁之后,可能会由于等待某个条件发生而进入等待状态(内置锁通过Object.wait()方法,显式锁通过Condition.await()方法),进入等待状态的线程会挂起并自动释放锁,这些线程会被放入到条件队列当中。synchronized对应的只有一个条件队列,而ReentrantLock可以有多个条件队列

Condition.signal();
Condition.signalAll();//ReentrantLock通过这两种方法唤醒

注意:ReentrantLock 底层是AQS的实现,使用内置锁时,对象本身既是一把锁又是一个条件队列;使用显式锁时,RenentrantLock的对象是锁,条件队列通过RenentrantLock.newCondition()方法获取,多次调用该方法可以得到多个条件队列。

二、深入锁底层

1.锁什么?

大家都知道锁是用来锁对象的,但事实上底层 “锁” 的是 “对象的头”.下面我们来看看这个对象头(object header) 到底是什么东西.

1-1.对象的存储布局

对象在内存中的存储布局分为三部分:

1.对象头
java里面用两个字来表示对象头,一个是Mark Word,一个是Klass pointer(Class MetaData Address)

(1)Mark Word(包含了自身运行时数据)
包括锁状态(lock,bkased_lock等),hashcode,GC分代年龄(age),线程持有的锁等… …

(2)Klass pointer(就是一个指针)
虚拟机通过这个指针来确定对象是哪个类的实例

64位虚拟机的对象组成(有锁无锁):
image-20210917143214445

2.实例数据
就是你在类里面写的东西()

3.对齐填充
JVM要求对象起始地址必须是8字节的整数倍(8字节对齐),所以不够8字节就由这部分来补充。

1-2.Java里的栈帧

Java中的栈帧随着方法调用而创建,随着方法结束销毁,可以理解为分配给方法的一块栈空间,每调用一个方法就创建一个栈帧。
因此,一般我们的main方法的栈帧都在栈底,由于栈是后进先出,所以你现在应该理解为什么main方法最后结束了吧!

1-3.代码演示

//先在pom.xml里面引入jol依赖,使得我们可以直观看到堆对象的组成
<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
</dependency>
public class A{
	private String name;
	private int age;
}
import org.openjdk.jol.info.ClassLayout;

public class Test {
    static A a = new A();

    public static void main(String[] args) {
        System.out.println(ClassLayout.parseInstance(a).toPrintable());
    }
}

输出演示:

请添加图片描述

1-4.锁升级过程

此处源自网络:原文连接

1、锁升级
锁的4中状态:无锁状态、偏向锁状态、轻量级锁状态、重量级锁状态(级别从低到高)

(1)偏向锁:

为什么要引入偏向锁?

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

偏向锁的升级:

当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致,如果一致(还是线程1获取锁对象),则无需使用CAS来加锁、解锁;如果不一致(其他线程,如线程2要竞争锁对象,而偏向锁不会主动释放因此还是存储的线程1的threadID),那么需要查看Java对象头中记录的线程1是否存活,如果没有存活,那么锁对象被重置为无锁状态,其它线程(线程2)可以竞争将其设置为偏向锁;如果存活,那么立刻查找该线程(线程1)的栈帧信息,如果还是需要继续持有这个锁对象,那么暂停当前线程1,撤销偏向锁,升级为轻量级锁,如果线程1 不再使用该锁对象,那么将锁对象状态设为无锁状态,重新偏向新的线程。

偏向锁的取消:

偏向锁是默认开启的,而且开始时间一般是比应用程序启动慢几秒,如果不想有这个延迟,那么可以使用-XX:BiasedLockingStartUpDelay=0;

如果不想要偏向锁,那么可以通过-XX:-UseBiasedLocking = false来设置;

(2)轻量级锁

为什么要引入轻量级锁?

轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

轻量级锁什么时候升级为重量级锁?

线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(称为DisplacedMarkWord),然后使用CAS把对象头中的内容替换为线程1存储的锁记录(DisplacedMarkWord)的地址;

如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁。

但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,比如10次或者100次,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁。重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。

*注意:为了避免无用的自旋,轻量级锁一旦膨胀为重量级锁就不会再降级为轻量级锁了;偏向锁升级为轻量级锁也不能再降级为偏向锁。一句话就是锁可以升级不可以降级,但是偏向锁状态可以被重置为无锁状态。

(3)这几种锁的优缺点(偏向锁、轻量级锁、重量级锁)

1、锁粗化
按理来说,同步块的作用范围应该尽可能小,仅在共享数据的实际作用域中才进行同步,这样做的目的是为了使需要同步的操作数量尽可能缩小,缩短阻塞时间,如果存在锁竞争,那么等待锁的线程也能尽快拿到锁。
但是加锁解锁也需要消耗资源,如果存在一系列的连续加锁解锁操作,可能会导致不必要的性能损耗。
锁粗化就是将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁,避免频繁的加锁解锁操作。

2、锁消除
Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,经过逃逸分析,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员七海

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值