并发(四、锁 synchronized)

1、用法

synchronized 用来保证代码的原子性。主要有三种用法:

  1. 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁
  2. 修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成 员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。
  3. 修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得当前 class 的锁。

如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,这是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。


2、实现原理

使用 synchronized 的时候,发现不用自己去 lock 和 unlock,是因为 JVM 把这 个事情做了。

  1. synchronized 修饰代码块时,JVM 采用 monitorenter 、 monitorexit 两个指令来实现同步, monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。
  2. synchronized 修饰同步方法时,JVM采用 ACC_SYNCHRONIZED 标记符来实现同步,这个标识指明了该方法是一个同步方法。
    1. 修饰普通方法时锁的是当前对象,相当于 synchronized(this)
    2. 修饰静态方法时锁的是当前类的 Class对象,相当于 synchronized(XXX.class)

● 隐式锁和显式锁

隐式锁又称内置锁、自动锁。其实就是字面意思。

● 互斥性

保证了同时只有一个线程持有锁,其他线程跟这个持有锁的线程互斥,处于等待中。

注意:互斥性针对的是同一把锁,即锁同一个对象。


3、锁住的是什么

monitorenter、monitorexit 或者 ACC_SYNCHRONIZED,其实都是基于对象的内置锁 (Intrinsic Lock)或称为监视器锁(Monitor Lock)。

每个对象都有一个内置锁,当一个线程获取了对象的内置锁时,其他线程必须等待该线程释放锁后才能获取锁。这样就保证了同一时间只有一个线程能够访问该对象的同步代码块或同步方法。

● Monitor 机制

  1. 同步性:Monitor 是一种同步工具或同步机制,它确保在任意时刻只有一个线程/进程能进入 Monitor 所定义的临界区,从而实现互斥的效果。也就是说,对象的所有方法都被互斥地执行,如同一个 Monitor 只有一个运行“许可”,任一个线程进入任何一个方法都需要获得这个“许可”,离开时把许可归还。
  2. 协作性:Monitor 通常提供 signal 机制,允许正持有许可的线程暂时放弃许可,等待某个监视条件成真。条件成立后,当前线程可以通知正在等待这个条件的线程,让它可以重新获得运行许可。
  3. 封装性:Monitor 将共享变量及对共享变量能够进行的所有操作集中在一个模块中,即将信号量及其操作原语“封装”在一个对象内部。这使得对共享变量的访问和操作更加集中和统一,便于管理和维护。
  4. 条件变量:Monitor 使用条件变量来实现线程间的协作与通信。通过 wait() 方法释放锁并等待条件满足,通过 notify() 或 notifyAll() 方法唤醒等待的线程。这有助于实现线程间的同步和通信。
  5. 线程调度:Monitor 中的线程遵循一定的调度规则,例如公平锁会按照先后顺序唤醒等待的线程。这有助于确保线程调度的公平性和效率。

总的来说,Monitor机制通过同步、协作、封装、条件变量和线程调度等特性,为多线程编程提供了一种有效的并发控制手段,有助于实现线程间的互斥访问和共享资源的同步,从而避免并发访问的竞态条件和数据不一致问题

Monitor 是依赖于底层操作系统的 Mutex Lock 实现的,是典型的重量级锁。

Java 会为每个 Object 的对象都分配一个 Monitor,也就是说所有的 Java 对象都是天生的 Monitor,因此 Monitor 在 Java 里面也被称为 内置锁(Instinsic Locks),或者叫 监视器锁(Monitor Locks)。

● Monitor 在 JVM 基本实现

Monitor 是线程私有的数据结构,是由 ObjectMonitor 实现的。

① 数据方面

ObjectMonitor 有几个关键属性:

  1. _owner:==指的是获得锁的线程,谁获得了锁,谁就是_owner。==获取 Monitor 对象的线程进入 _owner 区时, _count + 1。如果线程调用 了 wait() 方法,此时会释放 Monitor 对象, _owner 恢复为空, _count - 1。同时 该等待线程进入 _WaitSet 中,等待被唤醒。
  2. _WaitSet:==存放所有处在 wait 状态的线程的集合。==通常是双向循环链表。当线程在synchronized块中调用wait()方法时,它会被添加到 _WaitSet中,并释放monitor锁,等待其他线程调用notify()notifyAll()方法将其唤醒。
  3. _EntryList:==存放所有处在 block 状态的线程的集合。==也就是等待获取锁的线程集合。也是双向链表。当多个线程尝试获取同一个对象的锁时,未获取到锁的线程会被添加到 _EntryList 中等待,直到当前持有锁的线程释放锁。
  4. _recursions:==用于记录锁的重入次数。==当线程多次进入同一个 synchronized 块时,recursions 值会增加;每次线程退出 synchronized 块时,如果 recursions 值大于1,则仅将其减1,而不是将 count 和 recursions 都减到 0。
  5. _count:==记录当前线程获取锁的次数。==每次线程进入 synchronized 块时,_count 值会 +1;每次线程退出 synchronized 块时,_count 值会 -1。当 _count 值减少到 0 时,表示线程已经释放了 monitor锁。
  6. _object:这是一个指针,指向被该 ObjectMonitor 锁定的 Java对象。
  7. _cxq:这是一个单向链表,用于存储多个线程在争抢锁时首先进入的队列。这是一个先入后出(FILO)的栈结构。
ObjectMonitor() {
    _header = NULL;
    _count = 0; // 记录当前线程获取锁的次数
    _waiters = 0,
    _recursions = 0; // 锁的重入次数
    _object = NULL; // 这是一个指针,指向被该 ObjectMonitor 锁定的 Java对象
    _owner = NULL; // 这是一个指针,指向持有 ObjectMonitor 对象的线程
    _WaitSet = NULL; // 存放处于等待状态的线程队列
    _WaitSetLock = 0 ;
    _Responsible = NULL ;
    _succ = NULL ;
    _cxq = NULL ; // 这是一个单向链表,用于存储多个线程在争抢锁时首先进入的队列
    FreeNext = NULL ;
    _EntryList = NULL ; // 处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq = 0 ;
    _SpinClock = 0 ;
    OwnerIsThread = 0 ;
}

② 指令方面

显示加锁会在执行的指令集里加入以下两个指令:

  1. monitorenter:抢先进入的线程会优先拥有 Monitor 的 _owner ,此时计数器 +1。
  2. monitorexit:当执行完退出后,计数器 -1,归 0 后被其他进入的线程获得。

隐式加锁会加入此标记:flag: ACC_SYNCHRONIZED

③ 模型抽象

其抽象的结构和流程如下:

在这里插入图片描述

这个机制就类似于医院的就诊系统:

  • 门诊大厅(_EntryList):所有待进入的线程都必须先在入口Entry Set 挂号才有资格
  • 就诊室(_owner):就诊室 _owner 里只能有一个线程就诊,就诊完线程就自行离开
  • 候诊室(_WaitSet):就诊室繁忙时,进入等待区(Wait Set) ,就诊室空闲的时候就从等待区(Wait Set) 叫新的线程

在这里插入图片描述


4、可见性、有序性、可重入

● 如何保证可见性:

  • 线程加锁前,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新读取最新的值。
  • 线程加锁后,其它线程无法获取主内存中的共享变量。
  • 线程解锁前,必须把共享变量的最新值刷新到主内存中。

● 如何保证有序性:

synchronized 同步的代码块,具有排他性,一次只能被一个线程拥有,所以 synchronized 保证同一时刻,代码是单线程执行的。

因为 as-if-serial 语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。

所以 synchronized 保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

● 如何实现可重入:

synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。

synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会 -1,直到计数器清零,就释放锁了。


5、锁信息存放

  • Java对象

    • 对象头
      • Mark Word:存储对象自身的运行数据,如 hashcode、GC分代年龄、GC标记、锁状态标记、偏向时间戳(Epoch)等;长度:32位 JVM 中为 32bit,64位 JVM 中为 64bit
      • 指向类的指针:长度:32位 JVM 中为 32bit,64位 JVM 中为 64bit
      • 数组长度(只有数组对象才有):长度:32位和64位的 JVM 中都为 32bit
    • 实例数据
    • 对齐填充字符
  • 锁的状态:

    • 无锁

      • Mark Word 记录 对象hashcode、分代年龄、偏向锁标记、锁类型
    • 偏向锁

      • Mark Word 记录 线程id、偏向时间戳(Epoch)、分代年龄、偏向锁标记、锁类型
    • 轻量级锁

      • Mark Word 记录 指向栈中锁记录的指针、锁类型
    • 重量级锁

      • Mark Word 记录 指向重量级锁的指针、锁类型

      在这里插入图片描述


6、synchronized 优化

在 JDK1.6 之前,synchronized 的实现直接调用 ObjectMonitor 的 enter 和 exit,这种锁被称之为 重量级锁。从 JDK1.6 开始,HotSpot 虚拟机开发团队对Java中的锁进行优化, 如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了 synchronized 的性能。

  • 偏向锁:在无竞争的情况下,只是在 Mark Word 里存储当前线程指针,CAS操作都不做。
  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。
  • 自旋锁:为了避免在短时间内对线程进行阻塞、唤醒这样的重操作(减少不必要的CPU上下文切换),从而节省资源,提高程序的运行性能。因此在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式。
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。
锁类型优点缺点适用场景
偏向锁除了第一次加锁,其他的加锁、解锁基本不需要额外开销。如果发生线程竞争,需要先释放锁,这是个额外开销。只有一个线程来调用同步块的时候。
轻量级锁相互竞争的线程不会阻塞,提高程序响应速度。使用自旋会额外消耗CPU资源。1、追求响应时间
2、同步块执行速度快
重量级锁线程竞争不会自旋,不额外消耗CPU资源(但是会消耗操作系统级别的资源)。线程阻塞。
线程调度和切换需要消耗额外的资源。
1、追求吞吐量
2、同步块执行时间较长

CAS(Compare-and-Swap)操作是一种原子操作,它用于实现无锁编程,保证在多线程环境下对共享数据的并发访问是线程安全的。CAS操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值;否则,处理器不做任何操作。主要包含以下三个步骤:

  1. 读取:读取内存位置V的值。
  2. 比较:将读取到的值与期望的原值A进行比较。
  3. 交换:如果读取到的值等于A,则将内存位置V的值设置为新值B;否则开始自旋。

7、锁升级

锁升级方向:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁,这个方向基本上是不可逆的。

在这里插入图片描述

● 偏向锁

偏向锁总是被第一个占用它的线程拥有,这个线程就是锁的偏向线程(就是对第一个申请锁的线程偏心)。在偏向锁的MarkWord中,就有一块区域用来记录偏向线程的ID,这个是在锁第一次被拥有的时候记录的。因为记录了偏向线程ID,那么后续如果这个偏向线程进入和退出同步代码块的时候,就不需要再次加锁和解锁

在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。

偏向锁的释放:只有竞争才会去释放锁,只有偏向锁的线程不会主动释放偏向锁。

▶ 偏向锁的获取:

  1. 判断是否为可偏向状态:Mark Word中锁标志是否为 01,是否偏向锁是否为 1
  2. 如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤 ‘5’,否则进入步骤 ‘3’
  3. 通过CAS操作竞争锁,如果竞争成功,则将 Mark Word 中线程ID设置为当前线程ID,然后执行 ‘5’;竞争失败,则执行 ‘4’
  4. CAS获取偏向锁失败表示有竞争。当达到 safepoint 时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁 ,然后被阻塞在安全点的线程继续往下执行同步代码块
  5. 执行同步代码

▶ 偏向锁的撤销:

  1. 偏向锁不会主动释放(撤销),只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持有该偏向锁的线程栈状态,因此要等到 safepoint 时执行,此时持有该偏向锁的线程T有 ‘2’、‘3’ 两种情况
  2. 撤销:T线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态(该状态达到阈值20则执行批量重偏向)
  3. 升级:T线程还在同步代码块中,则将T线程的偏向锁升级为轻量级锁 ,当前线程执行轻量级锁状态下的锁获取步骤(该状态达到阈值40则执行批量撤销)

● 轻量级锁

在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。

▶ 轻量级锁的获取:

  1. 进行加锁操作时,JVM 会判断是否已经是重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象 Mark Word 复制到该锁记录中
  2. 复制成功之后,JVM 使用CAS操作将对象头 Mark Word 更新为指向锁记录的指针,并将锁记录里的 _owner 指针指向对象头的 Mark Word。如果成功,则执行 ‘3’,否则 执行 ‘4’
  3. 更新成功,则当前线程持有该对象锁,并且对象 Mark Word 锁标志设置为 00, 即表示此对象处于轻量级锁状态
  4. 更新失败,JVM 先检查对象 Mark Word 是否指向当前线程栈帧中的锁记录,如果是则执行 ‘5’,否则执行 ‘6’
  5. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word)为null,并指向 Mark Word 的锁对象,起到一个重入计数器的作用
  6. 表示该锁对象已经被其他线程抢占,则进行自旋等待 (默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁

精简的升级过程:

在这里插入图片描述

完整的升级过程:

在这里插入图片描述

8、Demo 生产者消费者模型

● 产品

package pcs;
import lombok.AllArgsConstructor;
import lombok.Data;

// 产品:冰激凌
@Data
@AllArgsConstructor
public class Icecream {
    private String name;
    private String taste;
}

● 公共区域

package pcs;
import lombok.SneakyThrows;
import java.util.LinkedList;

// 柜台(公共区域)
public class Counter {
    private LinkedList<Icecream> creamList = new LinkedList<>();
    private final int MAX_COUNT = 10;

    //生产者生产冰激凌放入柜台
    @SneakyThrows
    public synchronized void add(Icecream cream){
        while(creamList.size() >= MAX_COUNT){
            System.out.println(Thread.currentThread().getName() + ":柜台满了,停止生产+++");
            wait();
        }
        creamList.add(cream);
        System.out.println(Thread.currentThread().getName() + 
        	":冰激凌已放入柜台,目前有【" + creamList.size() + "】个!!!");
        notifyAll();
    }

    // 消费者消费一个冰激凌
    @SneakyThrows
    public synchronized Icecream take(){
        while(creamList.size() <= 0){
            System.out.println(Thread.currentThread().getName() + ":柜台空了,等待生产---");
            wait();
        }

        Icecream remove = creamList.remove();
        System.out.println(Thread.currentThread().getName() + 
        	":消费冰激凌1个,目前还剩【" + creamList.size() + "】个!!!");
        notifyAll();
        return remove;
    }

问题:

  1. 为什么用 while 而不是 if ?被唤醒后前面的条件未必满足,所以需要再判断一遍。
  2. 为什么用 notifyAll 而不是 notify ?防止死锁(生产者生产满了全部wait,消费者消费完恰好只唤醒消费者,直到消费光全部wait)
  3. notify 过早通知问题:比如线程A还没有wait,线程B已经notify并退出代码块,这时线程A才进入wait

● 生产者

package pcs;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.SneakyThrows;

// 生产者
@Data
@AllArgsConstructor
public class Producer extends Thread{
    private Counter counter;

    @SneakyThrows
    @Override
    public void run() {
        int num = 0;
        while(true){
            Icecream ic= new Icecream("ic-" + num, "口味-" + num);
            counter.add(ic);
            Thread.sleep(1000l);
            num++;
        }
    }
}

● 消费者

package pcs;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.SneakyThrows;

// 消费者
@Data
@AllArgsConstructor
public class Consumer extends Thread {
    private Counter counter;

    @SneakyThrows
    @Override
    public void run() {
        while(true){
            counter.take();
            Thread.sleep(1000l);
        }
    }
}

● 测试

package pcs;

public class PCTest {
    public static void main(String[] args) {
        Counter ct = new Counter();

        new Producer(ct).start();
        new Producer(ct).start();

        new Consumer(ct).start();
        new Consumer(ct).start();
    }
}
  • 26
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

纯纯的小白

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

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

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

打赏作者

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

抵扣说明:

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

余额充值