首页 > 其他分享 >JUC--如果简历上写了synchronized,需要掌握到什么程度?(万字图文深度解析synchronized关键字)

JUC--如果简历上写了synchronized,需要掌握到什么程度?(万字图文深度解析synchronized关键字)

时间:2025-01-05 11:57:55浏览次数:3  
标签:JUC 加锁 Monitor synchronized -- 对象 线程 轻量级

synchronized关键字(同步锁)

二、synchronized关键字(同步锁)

2.1是什么?有什么用?

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

在 Java 早期版本中,synchronized 属于 重量级锁,效率低下。这是因为监视器锁(monitor)是依赖于底层的操作系统的 Mutex Lock 来实现的,Java 的线程是映射到操作系统的原生线程之上的。如果要挂起或者唤醒一个线程,都需要操作系统帮忙完成,而操作系统实现线程之间的切换时需要从用户态转换到内核态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高。

不过,在 Java 6 之后, synchronized 引入了大量的优化如自旋锁、适应性自旋锁、锁消除、锁粗化、偏向锁、轻量级锁等技术来减少锁操作的开销,这些优化让 synchronized 锁的效率提升了很多。因此, synchronized 还是可以在实际项目中使用的,像 JDK 源码、很多开源框架都大量使用了 synchronized

2.2如何使用synchronized?

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
    //业务代码
}

2、修饰静态方法(锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {
    //业务代码
}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块(锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {
    //业务代码
}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能 .

2.3线程安全类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent 包下的类

这里说它们是线程安全的是指,多个线程调用它们同一个实例的某个方法时,是线程安全的。也可以理解为

Hashtable table = new Hashtable();
new Thread(()->{
    table.put("key", "value1");
}).start();
new Thread(()->{
    table.put("key", "value2");
}).start();
  • 它们的每个方法是原子的
  • 但注意它们多个方法的组合不是原子的
Hashtable table = new Hashtable();
// 线程1,线程2
if( table.get("key") == null) {
    table.put("key", value);
}

这个并不是线程安全的

不可变类线程安全性

String、Integer 等都是不可变类,因为其内部的状态不可以改变,因此它们的方法都是线程安全的

采取的是new一个新的对象,并将原来的value指向这个新创建的对象.

2.4锁原理

锁机制的核心基于对象头和 Monitor:

  1. Java 对象头通过 Mark Word 存储锁的状态(偏向锁、轻量级锁、重量级锁)。
  2. 每个对象可以关联一个 Monitor,实现线程间的锁竞争与管理。
  3. synchronized 关键字的实现依赖字节码指令 monitorentermonitorexit

Java对象头

每个 Java 对象都有一个对象头,包含用于支持锁机制的信息。

普通对象的结构(32 位虚拟机):

  • Mark Word (对象头):
    • 默认存储对象的哈希值和分代年龄。
    • 当对象进入锁状态时,Mark Word 的内容会变化,用于存储锁相关信息(如指向 Monitor 的指针)。
  • Class Pointer:
    • 指向对象的 Class 元数据。
      在这里插入图片描述

Mark Word 的锁标志位:

  • 32 位虚拟机:
    • 最后两位标志对象的锁状态。
    • 偏向锁 (101)轻量级锁 (00)重量级锁 (10)无锁 (01)
      在这里插入图片描述
  • 64 位虚拟机:
    • 类似,但结构更复杂,支持更多字段。
      在这里插入图片描述

Monitor对象(锁原理的关键)

Monitor 是 JVM 实现锁的关键结构,每个 Java 对象可以关联一个 Monitor(存储在堆中)。当一个线程对对象加锁(如进入 synchronized 块)时,会将该对象的 Mark Word 指向一个 Monitor。

Monitor 的结构:

  • Owner: 指向当前持有锁的线程(线程独占 Monitor)。
  • EntryList: 存储 BLOCKED 状态的线程(双向链表),等待获取锁。
  • WaitSet: 存储因调用 wait() 而进入 WAITING 状态的线程(条件不满足时进入)。
  • Count: 记录锁的重入次数。

Monitor 工作流程:

加锁:

  • 初始时 Monitor 的 Ownernull
  • 当线程 T2 执行 synchronized(obj) 时,Monitor 的 Owner 被设置为 T2
  • 对象头的 Mark Word 指向 Monitor 对象。
  • 对象原有的 Mark Word 被存储到线程栈的锁记录中(支持轻量级锁的回退机制)。

竞争锁:

  • 如果 T1 也尝试对 obj 加锁,但 T2 还未释放锁,T1 进入 EntryList(阻塞队列)。
    在这里插入图片描述

释放锁:

  • T2 执行完同步代码块后,Monitor 的 Owner 被设置为 null
  • Monitor 会唤醒 EntryList 中的线程进行竞争(竞争是非公平的)。

wait-notify 机制:

  • 线程在调用 wait() 时会从 EntryList 转入 WaitSet
  • 调用 notify() 时线程从 WaitSet 被唤醒,再次进入竞争。
    在这里插入图片描述

注意:

  • synchronized 必须是进入同一个对象的 Monitor 才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵从以上规则

字节码

synchronized 的字节码指令:

  • monitorenter:指示线程获取锁。
  • monitorexit:指示线程释放锁。
public static void main(String[] args) {
   Object lock = new Object();
   synchronized (lock) {
       System.out.println("ok");
  }
}

在这里插入图片描述

2.5synchronized锁升级

升级过程

synchronized 是可重入、不公平的重量级锁,所以可以对其进行优化

无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁// 随着竞争的增加,只能锁升级,不能降级

在这里插入图片描述

偏向锁(在jdk18中被抛弃)

偏向锁的基本概念和工作原理:

偏向锁是 Java 锁优化机制 的一种,设计目的是 优化只有一个线程多次获取锁的场景,减少无竞争情况下的同步开销。其核心思想是:锁会偏向于第一个获取它的线程,后续该线程再次获取锁时无需进行加锁操作。以下是偏向锁的详细原理:

偏向锁的工作流程:

  1. 初始状态:

    • 对象在创建时,Mark Word 的最后 3 位标记为 101(表示偏向锁状态)。
    • 偏向锁默认是延迟开启的(JDK8 中延迟约 4 秒)。可以通过 JVM 参数 -XX:BiasedLockingStartupDelay=0 禁用延迟。
  2. 第一次加锁:

    • 当线程 T1 第一次获取该锁时,通过 CAS 操作将线程 ID 写入 Mark Word,同时保持偏向状态。
    • 如果 CAS 成功,后续 T1 再次进入同步块时,只需检查线程 ID,无需加锁或解锁操作(无竞争开销)。
  3. 锁撤销(Revoke Bias):

    • 竞争出现时

      (其他线程尝试获取锁),偏向锁会撤销:

      • 如果没有竞争,可能触发重偏向,即偏向新的线程。
      • 如果竞争严重,会升级为轻量级锁。
      • JVM 支持批量撤销,避免重复撤销开销。
  4. 无法偏向的情况:

    • 对象调用了 hashCode() 方法,因为偏向锁的 Mark Word 无法存储哈希值。
    • 使用了 wait/notify 等需要依赖监视器的操作。
    • 偏向锁被 JVM 参数 -XX:-UseBiasedLocking 禁用。

轻量级锁

轻量级锁是 Java 锁优化机制 中的一种,设计目标是 减少无竞争情况下的加锁和解锁开销,适用于多线程加锁但加锁时间错开的场景。与重量级锁相比,轻量级锁避免了线程阻塞,提高了性能。

可重入锁:线程可以进入任何一个它已经拥有的锁所同步着的代码块,可重入锁最大的作用是避免死锁

轻量级锁的核心原理:轻量级锁通过在每个线程的栈帧中创建一个 锁记录(Lock Record) 来存储锁对象的相关信息,同时结合 CAS 操作(Compare-And-Swap) 来实现加锁和解锁。

锁重入实例:

static final Object obj = new Object();
public static void method1() {
    synchronized( obj ) {
        // 同步块 A
        method2();
    }
}
public static void method2() {
    synchronized( obj ) {
    	// 同步块 B
    }
}

轻量级锁的加锁流程:

  1. 创建锁记录(Lock Record)

    • 每个线程的栈帧包含一个锁记录结构,记录加锁对象的状态。

    在这里插入图片描述

  2. CAS 操作尝试加锁

    • 让锁记录中的 Object Reference 指向目标对象,并用 CAS 操作替换对象头(Mark Word)内容,将 Mark Word 的原值存入锁记录中。

    • 如果 CAS 成功:

      • 对象头存储了锁记录地址,状态改为 00(表示轻量级锁),表示该线程成功获取轻量级锁。

      在这里插入图片描述

    • 如果 CAS 失败:

      • 无竞争但线程重入: 添加一条新的锁记录,记录重入次数。

      在这里插入图片描述

      • 有竞争: 轻量级锁膨胀为重量级锁。

轻量级锁的解锁流程:

  1. 检查锁记录状态:
    • 如果有空的锁记录,表示锁有重入,重置锁记录,重入计数减 1。
    • 如果锁记录非空,用 CAS 将 Mark Word 恢复到对象头。
  2. CAS 成功或失败:
    • CAS 成功: 表示解锁成功,轻量级锁结束。
    • CAS 失败: 表示锁已经膨胀为重量级锁,进入重量级锁的解锁流程。

锁膨胀(重量级锁)

重量级锁是 Java 中的一种 线程同步机制,用于解决多线程竞争严重时的锁管理问题。当轻量级锁或偏向锁无法满足需求(如存在多个线程同时竞争锁),锁会膨胀为重量级锁。重量级锁通过 Monitor(监视器锁) 实现,依赖于操作系统的 互斥量(Mutex),会导致线程阻塞和上下文切换。

重量级锁的加锁流程

  1. 轻量级锁膨胀触发:

    • 当线程 T1 尝试加轻量级锁时,发现另一个线程 T0 已经持有该锁,CAS 操作失败。

    在这里插入图片描述

    • JVM 判断存在竞争,进入锁膨胀流程,将轻量级锁升级为重量级锁。
  2. 膨胀为重量级锁:

    • JVM 为目标对象分配一个 Monitor 对象
    • 对象头(Mark Word)中存储 Monitor 对象的地址,状态设置为 10(重量级锁)。

    在这里插入图片描述

    • Monitor 对象包含以下关键字段:
      • Owner:记录当前持有锁的线程(如 Thread-0)。
      • EntryList:存储尝试获取锁但被阻塞的线程(如 Thread-1)。
  3. 竞争线程阻塞:

    • Thread-1 被加入 Monitor 的 EntryList 中,线程状态变为 BLOCKED,挂起等待锁释放。

重量级锁的解锁流程:

  1. 持锁线程释放锁:
    • 持有锁的线程(Thread-0)退出同步代码块,尝试使用 CAS 操作恢复对象头的 Mark Word。
    • 如果 CAS 操作失败(因锁已经膨胀),进入重量级锁解锁流程。
  2. Monitor 解锁:
    • 根据对象头的 Monitor 地址找到对应的 Monitor 对象。
    • 将 Monitor 的 Owner 字段设置为 null
    • 唤醒 EntryList 中等待的线程(如 Thread-1),尝试获取锁。
  3. 线程重新竞争锁:
    • 被唤醒的线程(Thread-1)重新竞争锁,CAS 成功后获取锁,成为新的 Owner

2.6锁优化

锁优化是 Java 中提升并发性能的重要手段,以下是常见的优化方式:

  1. 自旋锁:线程在获取锁失败时,通过自旋尝试重新获取锁,适用于短时间锁的竞争,但会占用 CPU 资源。
  2. 锁消除:通过逃逸分析发现不必要的锁,直接移除同步代码,减少无意义的锁开销。
  3. 锁粗化:将多次对同一对象的锁操作合并,减少加锁和解锁的次数,提高性能。
  4. 适应性锁:JVM 根据锁的使用情况动态调整策略(如偏向锁、自旋锁等),优化锁的性能。

自旋锁

**核心思想:**当线程尝试获取锁失败时,线程不会立即进入阻塞状态,而是通过 自旋(循环检查锁的状态)尝试重新获取锁,以避免线程阻塞和上下文切换的开销。

优点:

  • 减少线程从用户态到内核态的切换,提高性能。
  • 适用于锁持有时间短的场景。

缺点:

  • 自旋会占用 CPU 时间,多线程长时间自旋可能造成资源浪费。
  • 在单核 CPU 上,自旋毫无意义,因为同一时刻只能运行一个线程。

自旋锁的特点:

  • Java 6 引入了自适应自旋锁:
    • 如果前一次自旋成功,系统会增加自旋次数。
    • 如果多次自旋失败,系统会减少自旋次数甚至直接阻塞线程。
  • 在 Java 7 之后,是否开启自旋完全由 JVM 自动控制,开发者无法干预。

锁消除

核心思想:在代码编译时,如果 JVM 通过 逃逸分析 检测到锁对象没有线程安全问题(即不会被其他线程访问到),会将锁直接消除,避免无意义的同步操作。

实现方式:

  • 逃逸分析:判断对象是否只在当前线程内使用。
  • 如果对象未逃逸,可以将其视为线程私有,不需要加锁。

适用场景:

  • 锁用于局部变量且没有被外部线程访问。
  • 如常见的 StringBufferStringBuilder 操作中,如果仅在单线程中使用,JVM 会优化去掉同步块。

示例:

public void example() {
    StringBuilder sb = new StringBuilder();
    sb.append("Hello");
    sb.append("World");
}

在这种情况下,JVM 会消除同步锁,优化为无锁操作。

锁粗化

核心思想:将多个连续的加锁和解锁操作合并为一个锁操作,避免频繁的加锁和解锁带来的开销。

优点:

  • 减少锁操作的频率,提高性能。
  • 适用于多个锁操作针对同一个对象的场景。

实现方式:

  • JVM 会在运行时对锁操作进行优化,将锁的作用范围扩大到所有相关操作的外部。

示例: 原始代码:

public void example() {
    synchronized (lock) {
        doSomething1();
    }
    synchronized (lock) {
        doSomething2();
    }
}

锁粗化后:

public void example() {
    synchronized (lock) {
        doSomething1();
        doSomething2();
    }
}

2.7活跃性

死锁

死锁:多个线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放,由于线程被无限期地阻塞,因此程序不可能正常终止

Java 死锁产生的四个必要条件:

  1. 互斥条件,即当资源被一个线程使用(占有)时,别的线程不能使用

  2. 不可剥夺条件,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放

  3. 请求和保持条件,即当资源请求者在请求其他的资源的同时保持对原有资源的占有

  4. 循环等待条件,即存在一个等待循环队列:p1 要 p2 的资源,p2 要 p1 的资源,形成了一个等待环

四个条件都成立的时候,便形成死锁。死锁情况下打破上述任何一个条件,便可让死锁消失

定位

定位死锁的方法:

  • 使用 jps 定位进程 id,再用 jstack id 定位死锁,找到死锁的线程去查看源码,解决优化

在这里插入图片描述

  • Linux下可以通过 top 先定位到 CPU 占用高的 Java 进程,再利用 top -Hp 进程id 来定位是哪个线程,最后再用 jstack 的输出来看各个线程栈
  • 避免死锁:避免死锁要注意加锁顺序
  • 可以使用 jconsole 工具,在 jdk\bin 目录下

活锁

活锁:指的是任务或者执行者没有被阻塞,由于某些条件没有满足,导致一直重复尝试—失败—尝试—失败的过程

两个线程互相改变对方的结束条件,最后谁也无法结束:

class TestLiveLock {
   static volatile int count = 10;
   static final Object lock = new Object();
   public static void main(String[] args) {
       new Thread(() -> {
           // 期望减到 0 退出循环
           while (count > 0) {
               Thread.sleep(200);
               count--;
               System.out.println("线程一count:" + count);
          }
      }, "t1").start();
       new Thread(() -> {
           // 期望超过 20 退出循环
           while (count < 20) {
               Thread.sleep(200);
               count++;
               System.out.println("线程二count:"+ count);
          }
      }, "t2").start();
  }
}

饥饿

饥饿:一个线程由于优先级太低,始终得不到 CPU 调度执行,也不能够结束

2.8wait/notify原理

对比 sleep():

  • 原理不同:sleep() 方法是属于 Thread 类,是线程用来控制自身流程的,使此线程暂停执行一段时间而把执行机会让给其他线程;wait() 方法属于 Object 类,用于线程间通信
  • 锁的处理机制不同:调用 sleep() 方法的过程中,线程不会释放对象锁,当调用 wait() 方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池(不释放锁其他线程怎么抢占到锁执行唤醒操作),但是都会释放 CPU
  • 使用区域不同:wait() 方法必须放在**同步控制方法和同步代码块(先获取锁)**中使用,sleep() 方法则可以放在任何地方使用

底层原理:

  • Owner 线程发现条件不满足,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner 线程调用 notify 或 notifyAll 时唤醒,唤醒后并不意味者立刻获得锁,需要进入 EntryList 重新竞争

在这里插入图片描述

需要获取对象锁后才可以调用 锁对象.wait(),notify 随机唤醒一个线程,notifyAll 唤醒所有线程去竞争 CPU

Object 类 API:

public final void notify():唤醒正在等待对象监视器的单个线程。
public final void notifyAll():唤醒正在等待对象监视器的所有线程。
public final void wait():导致当前线程等待,直到另一个线程调用该对象的 notify() 方法或 notifyAll()方法。
public final native void wait(long timeout):有时限的等待, 到n毫秒后结束等待,或是被唤醒

标签:JUC,加锁,Monitor,synchronized,--,对象,线程,轻量级
From: https://blog.csdn.net/m0_51275144/article/details/144943218

相关文章

  • 20241318 《计算机基础与程序设计》课程总结
    每周作业链接汇总第一周作业:阅读浏览教材《计算机科学概论》并提出自己的问题,基于AI进行学习第二周作业:自学计算机科学概论(第七版)第1章并完成云班课测试、《C语言程序设计》第1章并完成云班课测试第三周作业:自学计算机科学概论(第七版)第2章,第3章并完成云班课测试、《C语言程序......
  • 虚拟主机迁移数据及数据库导入的最佳实践
    问题描述:当需要将一个虚拟主机上的网站和数据库迁移到另一个虚拟主机时,应该采取哪些步骤来确保数据完整性和安全性?特别是当目标主机已经存在数据时,应该如何处理现有数据?此外,如果数据库无法直接导入,应如何解决这一问题?答案:您好,在进行虚拟主机迁移时,确保数据完整性和安全性至关重......
  • 服务器网站助手升级后MySQL服务自动关闭的原因及解决方案
    用户在升级服务器网站助手后,发现MySQL服务自动关闭,影响了网站的正常运行。此外,用户还遇到了香港IP地址80端口不通的问题。解决方案:步骤描述1.提交工单首先,用户需要提交云服务器数据库工单,选择“【主机租用/vps/云主机】—【数据库设置】—【数据库服务启动失败/使用......
  • 解决网站设置为只读后仍被入侵写入文件的问题
    问题描述: 用户将网站根目录设置为只读权限,但仍然发现有恶意文件被写入。请求协助清理并恢复数据,确保网站安全。回答: 您好,针对您提到的网站设置为只读后仍被入侵写入文件的问题,我们已经为您进行了以下处理和建议:数据恢复与清理:我们已经恢复了主机空间的数据备份,并清除了一......
  • 数据盘扩容失败,如何解决?
    当您在尝试对数据盘进行扩容时遇到失败,这可能是由多种因素引起的。为了帮助您准确诊断并解决问题,以下是详细的排查步骤和解决方案:确认扩容操作无误:首先,请确保在扩容过程中没有遗漏任何关键步骤。扩容数据盘通常涉及创建新的分区、格式化新分区、挂载新分区并将原有数据迁移过......
  • 宝塔面板用户名和密码重置
    当您需要重置宝塔面板的用户名和密码时,确保操作正确且不影响现有应用是非常重要的。以下是详细的步骤说明和注意事项,帮助您顺利完成宝塔面板用户名和密码的重置:确认服务器登录信息:首先,请确保您拥有服务器的远程登录信息(如SSH用户名、密码)。这是进行后续操作的基础。如果不确定......
  • 宝塔面板密码忘记,如何重置?
    针对您提到的宝塔面板密码忘记的问题,我们提供了详细的解决方案。宝塔面板是许多用户常用的服务器管理工具,忘记密码确实会给日常操作带来不便。以下是几种常见且有效的重置方法:通过命令行重置:登录到服务器,打开终端或SSH客户端。输入命令bt进入宝塔命令行界面。根据提示选择......
  • 云服务器根目录扩容后磁盘空间未增加
    问题描述:我已经升级了云服务器的配置,但根目录的空间大小并未增加。请帮我检查并解决这个问题。详情回答:您好!感谢您使用我们的云服务器服务。根据您的描述,您已经升级了云服务器的配置,但根目录的空间大小并未增加。这种情况通常是由于磁盘挂载或分区设置不当引起的。下面我们将为......
  • 如何解决BT面板无法登录的问题?
    当您遇到无法登录BT面板的情况时,可能是由多种原因引起的。以下是一些常见的排查步骤和解决方案,帮助您快速解决问题:检查用户名和密码:首先,确认您使用的用户名和密码是否正确。默认情况下,BT面板的登录用户名通常是 admin 或 cp,密码则是您在安装时设置的初始密码。如果您忘记了......
  • 罗振宇2025年跨年演讲金句,够我好好活1年
    罗振宇老师跨年演讲今年特别成功,看有1000多万的人在线一起跨年,有一些金句和大家分享:1.一个消息来了,咱哪分得清它是好是坏?如果它最后是好消息,那它只是我们努力的模样。2.只要想完成,总有办法可以完成,或者更接近于完成。3.悲观只是一个看法,乐观却是一种行动。4.送给所有正在一......