Java-并发-synchronized

文章目录层级较多,参考我这篇文章来进行展开,方便阅读:博客园SimpleMemory主题如何浮动目录显示


参考文章:

synchronized底层monitor原理

Synchronized实现原理,你知道多少?

Java对象的内存布局详解——超市薯片是怎么摆在货架上的?

【JUC】Java对象内存布局和对象头

0.背景

首先记住synchronized的3个特点:

  • 独占锁

    在同一时间只能有一个线程持有该锁。当一个线程获取到synchronized锁后,其他线程必须等待该线程释放锁之后才能获取锁。

  • 可重入锁

    同一个线程在持有锁的情况下,可以再次获取该锁而不会发生死锁。这种机制使得同一个线程可以重复进入自己已经拥有的锁的同步代码块,而不会被阻塞。

  • 非公平锁

    当多个线程等待同一个锁时,JVM会从等待队列中随机选择一个线程来获取锁,而不是按照先来先服务的原则。这样可能会导致某些线程长时间等待,而其他线程不断地获取锁。

1.概念

在HotSpot虚拟机中,monitor是由ObjectMonitor实现的,源码由C++实现,位于HotSpot虚拟机源码ObjectMonitor.hpp文件中src/share/vm/runtime/objectMonitor.hpp

synchronized的底层是基于Java的监视器monitor的,监视器是用来实现线程同步的一种基本机制,内置在每个Java对象中,用于解决多线程之间的互斥和协作问题。

在Java中,监视器(monitor)是与对象关联的,而不是与类、接口或抽象类直接关联。

这里的知识搜了下比较复杂,我们可以先这么简单的理解:

所有对象都有潜在的能力关联监视器,但并不是说每个对象都默认地与一个监视器实体积极关联。

对象的监视器(或说锁)只在必要时,即对象被用于synchronized时,才被激活和使用。

类比我们常规的加锁操作,锁这玩意,是一个外部的东西。

比如老生常谈的厕所这个例子,厕所门上的锁,相对于厕所本身,是个外部的、第三方的东西,我们依据它来裁定使用权。

两个线程出现问题了,涉及到竞态,我们就要寻求一个外部的东西来加锁,这个外部的东西,就是监视器。

这是一种思想,那我们抽象下,不用监视器,我们只要用一个外部的、第三方的能拿来判定的东西就好了。

比如说,分布式环境下,两个程序需要竞争了,我们是不是开始寻求一个外部的裁判redis,来,你们谁能给我拿到,谁就可以操作。

嗯,又比如我两都要去参加王者荣耀的线下比赛,但是我们都是打野位置,到底谁去?嗯 ,简单的方法就是今晚看谁先上2100分,这就是外部的一个判定条件。

就加了个synchronized关键字啊,都没有写锁的代码,当时是底层来帮你处理啦。

现在我们先记住以下两点:

  • 每个对象可以有1个与之关联的锁(也称为互斥锁或monitor锁),这把锁是在对象被用作同步时由 JVM 动态管理的。
  • 每个对象都有3个用于线程间通信的方法
    • wait()
    • notify()
    • notifyAll()

2.锁和锁的对象

我们可以先简单的归纳出一个基本的运行模式:

  • 当一个线程尝试进入一个由 synchronized 关键字同步的代码块或方法时,它需要获取那个对象的监视器。
  • 如果监视器是空闲的,则线程获取它,并进入同步代码块。
  • 如果监视器已经被另一个线程持有,则当前线程将阻塞,直到监视器被释放。

2.1 锁定

当使用 synchronized 关键字时,锁定某个对象或类的 Class 对象。

实际上意味着任何线程想要执行该对象或类中的同步代码部分,它必须首先获得那个特定对象或 Class 对象背后的锁。

什么是锁定,锁定就是说,你不能用了,你得等解锁后才能继续。

它是一个状态,代表着已经上锁了,不能乱用。

锁定xx,到底蕴含着什么意思啊?

锁定的关键,在于你锁定谁了,你就必须持有谁背后的那把锁,才能继续操作。

关键字持有,看到锁定,我们就要思考,谁持有?

回顾下,synchronized 主要作用于3个地方:

  • 实例方法
  • 静态方法
  • 同步代码块

2.1.1 实例方法

synchronized 关键字用于实例方法时,它会锁定调用该方法的对象(通常称为 this 对象)。

这意味着,要执行这个同步方法,线程必须首先获得这个对象的锁。

public class Example {
    public synchronized void instanceMethod() {
        // code
    }
}

实例方法,必然是由某个实例对象调用的,假设这个实例对象是A。

假设我们有线程1和2要使用这个对象A的instanceMethod()方法。

现在,线程1获取到这个对象A上的锁后,线程1就锁定对象A,这个时候,线程2未持有对象A的锁,所以,线程2无法执行。

2.1.2 静态方法

synchronized 关键字用于静态方法时,它会锁定这个类的所有对象共享的 Class 对象。

因为静态方法是属于类的而不是某个对象,所以用类本身作为锁的对象。

public class Example {
    public static synchronized void staticMethod() {
        // code
    }
}

静态方法,是直接由类进行调用的。

假设我们有线程1和2要使用这个Example类的staticMethod()方法。

现在,线程1获取到这个Example类的锁后,线程1就锁定Example类,这个时候,线程2未持有Example类的锁,所以,线程2无法执行。

2.1.3 同步代码块

这种方式允许你指定锁定的对象,提供更细粒度的锁控制。

可以是任何对象,例如一个特定的对象实例、类的 Class 对象,或者是方法中的任何其他对象。

public class Example {
    private final Object lock = new Object();

    public void method() {
        synchronized(lock) {
            // synchronized block code
        }
    }
}

同步代码块,可能位于一个实例方法中,也可能位于一个静态方法中,比如下图这样。

image-20240511231219543

那么,还是两个线程1和线程2,他们运行method1()method2()会怎么样呢?

  • method1()

    • 这个方法使用一个非静态同步代码块,锁定的是 lock 对象。
    • lock 是一个实例变量,不同的实例将会拥有不同的 lock 对象。
    • 如果两个线程使用同一个实例调用 method1(),它们将会互相阻塞,因为它们试图获取同一个 lock 对象的监视器。
    • 如果每个线程使用不同的实例调用 method1(),它们将不会互相阻塞,因为每个实例的 lock 对象是不同的,它们各自锁定自己的锁对象。
  • method2()

    • 这个方法使用一个静态同步代码块,锁定的是 LOCK2 对象。
    • LOCK2 是一个静态变量,所有的 类实例共享同一个 LOCK2 对象。
    • 无论哪个线程或哪个实例调用 method2(),它们都会试图获取同一个 LOCK2 对象的监视器。
    • 意味着全局只有一个线程可以在任何时刻进入 method2() 的同步代码块,如果有多个线程尝试调用 method2(),它们将会因为 LOCK2 而互相阻塞。

2.2 扩展

上面的例子看下来,好像懂了,又好像没懂。到底有没有一个简单点的方法论,能让我判断xx线程能不能执行啊?

关键点在于,你要找到这把锁到底锁在哪,当前线程能拿到吗?

跟我重复背诵:1.锁在哪 2.能拿到吗

跟我重复背诵:1.锁在哪 2.能拿到吗

跟我重复背诵:1.锁在哪 2.能拿到吗

2.2.1 demo1

现在再来结合另一个例子演示下

package cn.yang37.thread;

import lombok.extern.slf4j.Slf4j;

/**
 * @description:
 * @class: MySync
 * @author: yang37z@qq.com
 * @date: 2024/5/15 11:06
 * @version: 1.0
 */
@Slf4j
public class MySync {

    public static void main(String[] args) {
        Sync sync = new Sync();

        new Thread(
                () -> {
                    sync.m1();
                }, "t1")
                .start();

        new Thread(
                () -> {
                    sync.m2();
                }, "t2")
                .start();

        log.info("mmmmmmmmmmmmmm");
    }
}

@Slf4j
class Sync {

    public void m1() {
        try {
            // Thread.sleep(1000);
            log.info("11111111111111");
        } catch (Exception e) {
            log.error("error...", e);
        }
    }

    public void m2() {
        log.info("22222222222222");
    }
}

main方法中启动了2个线程,分别运行了m1、m2两个方法,这个时候,没有使用synchronized关键字,可以看到输出是无序的,依赖于cpu对t1、t2、main这3个线程的调度顺序。

  • m、2、1

image-20240515112241187

  • 2、1、m

image-20240515112214865

  • 2、m、1

image-20240515112305260

  • 1、m、2

image-20240515112320894

2.2.1.1 添加sleep

尝试为m1方法添加sleep(1000),可以看到,此时会最后输出。

因为线程1、2近似同时启动,由于线程1中会休眠1s,故最后输出信息。

image-20240515112711171

2.2.1.2 m1和m2添加synchronized

将m1和m2变成同步方法,现在再来运行,注意,m1中会sleep。

image-20240515113259592

现在可以看到,111将会固定比222先输出。

方法:看锁定的是谁,当前线程中能拿到吗?

此时,synchronized锁定的是当前对象,线程1和线程2都是访问的sync对象。

  Sync sync = new Sync();

线程1启动后,立马占用了sync这个对象的锁。此时,哪怕线程2想要运行,也拿不到锁,得等线程1先运行完释放。

所以现象就是,222肯定得等111输出完释放锁。

2.2.1.3 m2移除synchronized

image-20240515113910627

现在,又可以看到222比111先输出了。

还是刚才的点,因为m2不是同步方法,不需要获取锁,所以哪怕m1占用了也没事。

2.2.1.4 同时调用m1

image-20240515114037408

此时,由于t1先持有锁,t2必须等待,所以,t2固定后输出111。

2.2.1.5 不同的对象调用m1

image-20240515114209490

此时,由于线程1和2持有的锁不同,一个是sync对象的,一个是sync2对象的,所以不会等待,可以看到t2线程先输出的情况。

因为没有竞争,t1先输出也是正常的。

image-20240515114325566

2.2.1.6 修改为静态方法

虽然会告警不应该通过实例对象调用静态方法,但是此处为了演示,我们先忽略。

image-20240515114612875

此时,线程1固定线程2先输出,哪怕它们用的对象不同,因为,此时锁定的是Sync.class这个类,线程1和2是存在竞争的。

2.2.1.7 代码块

调整为静态代码块,使用this。

image-20240515114909857

this啥意思,就是调用方法的这个玩意,这里是谁,就是sync这个对象,所以,t1固定比t2先输出。

2.2.1.7 代码块中使用同一个对象

为m1添加入参,锁对象由入参传入。

image-20240515115135161

此时t1固定比t2先输出,因为它们都使用的o1对象。

2.2.1.8 代码块中使用不同的对象

调整传入的锁对象分别为o1和o2,可以看到,此时t2可能比t1先输出。

image-20240515115342691

t1比t2先输出,也是正常的。

image-20240515115308427

因为,此时锁定的对象是不同的,分别是o1和o2,所以,不存在竞争关系。

2.2.2 demo2

代码里搜索了下synchronized,咱也不知道这是哪个类,主打一个随机展示。

image-20240515121042324

看到这里有个synchronized,如何分析?

可以看到里面的fSymbolTable,所以关键点就是,fSymbolTable这个玩意咱们的线程1和线程2如何访问的。

找到这个fSymbolTable的来源,哦原来这是个成员变量,从构造方法传入的。

image-20240515121214346

看下这几个构造方法:

    public SynchronizedSymbolTable(SymbolTable symbolTable) {
        fSymbolTable = symbolTable;
    } // <init>(SymbolTable)

    // construct synchronized symbol table of default size
    public SynchronizedSymbolTable() {
        fSymbolTable = new SymbolTable();
    } // init()

    // construct synchronized symbol table of given size
    public SynchronizedSymbolTable(int size) {
        fSymbolTable = new SymbolTable(size);
    } // init(int)

哦,无参没有就新建,有就用这个,同时支持一个传递size的。

那么问题的关键点就是,多个线程能否同时访问到SynchronizedSymbolTable中的fSymbolTable。

    protected SymbolTable fSymbolTable;

我们可以看到方法都是非static的,即实例方法。

image-20240515122022353

实例方法怎么用,实例方法肯定是new出来的对象用的,那么,new出来的这个SynchronizedSymbolTable对象,内部的fSymbolTable是不是唯一的?

显然,每个类中的fSymbolTable这个变量是唯一的,多个线程通过同一个SynchronizedSymbolTable对象访问时,访问到的fSymbolTable是固定的。

故,我们可以有以下结论:

这个SynchronizedSymbolTable的addSymbol()方法是线程安全的

多个线程执行addSymbol()方法时,会锁住SynchronizedSymbolTable中的fSymbolTable,此时别的线程无法操作。


来来来,上上最新的CHATGpt 4o,让它分析下。

image-20240515122720816

2.2.2.1 整体角度
1.方法定义
public String addSymbol(String symbol) {
    synchronized (fSymbolTable) {
        return fSymbolTable.addSymbol(symbol);
    }
}
2.并发安全
  • 同步机制:该方法使用 synchronized 关键字对 fSymbolTable 对象加锁,确保同一时间只有一个线程能够执行 addSymbol 方法,防止并发问题。
  • 锁的对象:锁住的是 fSymbolTable 实例,即实际的 SymbolTable 对象。
3.竞争关系举例
  • 竞争关系:多个线程同时调用 addSymbol 方法时,只有一个线程能获取到 fSymbolTable 的锁,其它线程必须等待锁的释放。

  • 实际例子

    • 场景:假设有两个线程 Thread AThread B,都试图将不同的符号添加到 SymbolTable 中。

    • 操作流程

      • Thread A 调用 addSymbol,获取 fSymbolTable 的锁。

      • Thread A 完成 addSymbol 操作之前,Thread B 也调用 addSymbol,但必须等待 Thread A 释放锁。

      • Thread A 释放锁后,Thread B 获取锁并执行 addSymbol 方法。

4.实际应用例子

假设一个编译器使用 SynchronizedSymbolTable 来存储唯一的标识符。多个线程解析代码并将标识符添加到符号表:

java复制代码SynchronizedSymbolTable symbolTable = new SynchronizedSymbolTable();

// Thread A
new Thread(() -> {
    symbolTable.addSymbol("variableA");
}).start();

// Thread B
new Thread(() -> {
    symbolTable.addSymbol("variableB");
}).start();

在上述例子中,Thread AThread B 会竞争 fSymbolTable 的锁,确保每次只有一个线程能够向符号表中添加符号,从而保证并发安全。

2.2.2.2 成员变量角度

每个 SynchronizedSymbolTable 实例中的 fSymbolTable 变量是唯一的。换句话说,fSymbolTable 是每个 SynchronizedSymbolTable 对象的成员变量,每个实例都有自己的 fSymbolTable 对象。

1.实例唯一性
  • 每个 SynchronizedSymbolTable 实例在创建时,会初始化自己的 fSymbolTable 变量,指向一个新的或现有的 SymbolTable 对象。

  • 例如:

    SynchronizedSymbolTable table1 = new SynchronizedSymbolTable();
    SynchronizedSymbolTable table2 = new SynchronizedSymbolTable();
    
2.锁的作用
  • synchronized (fSymbolTable) 对象锁定 fSymbolTable 的实例,确保每次只有一个线程可以访问该 SymbolTable 实例的方法。
  • 因此,锁的范围仅限于每个 SynchronizedSymbolTable 实例的 fSymbolTable,不会影响其他 SynchronizedSymbolTable 实例。

3.原理

synchronized的底层实现是完全依赖JVM虚拟机的,经过上文,我们可以知道synchronized可以锁到对象或者一个类上。

先来看看对象的存储结构。

3.1 虚拟机

Java虚拟机 (JVM) 是一种运行Java字节码的抽象计算机。

JVM使Java程序具有跨平台能力,即"一次编写,到处运行"。

Java代码被编译成字节码后,JVM负责将字节码解释或编译成特定平台的机器码,并在该平台上运行。

嗯,咱们不是有个概念叫做运行时嘛,这个就相当于Java的运行时。

常见的JVM实现:

序号 JVM 实现 特点
1 Oracle HotSpot 默认的Sun/Oracle JVM,性能优化良好,广泛使用
2 OpenJ9 由IBM开发,开源JVM,强调低内存占用和高启动性能
3 GraalVM 支持多语言执行环境,具有先进的即时编译器和原生映像生成能力
4 Zulu Azul公司提供的开源JVM,实现多种平台和配置,注重企业级支持

嗯,还记得sun公司吗,2009年时被oracle收购。

3.2 对象结构

基于hotSpot虚拟机

3.2.1 基本结构

HotSpot 虚拟机中对象在内存中可分为对象头(Header)、实例数据(Instance Data)和对齐填充(Padding),基本结构如下:

img

  • 对象头 (Header)

    • 标记字段Mark Word:存储对象的哈希码、GC标志、状态等信息。
    • 元数据指针:指向对象的类元数据,确定对象的类型信息。
    • 数组长度(仅数组对象):存储数组的长度。
  • 实例数据 (Instance Data)

    • 包含对象的实际数据字段,包括父类和子类的所有实例变量,按声明顺序存储。
  • 填充

    • 虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

对象头中的标记字段Mark Word,用于存储对象自身的运行时数据,其组成如下图:

img

这张图,最上面是表头,下面的无锁、偏向锁等是状态信息。

即根据锁的状态(无锁、偏向锁、轻量级锁、重量级锁、GC标志)不同,对象头的信息如下

  • 无锁状态

    • 25bit 未使用
    • 31bit 对象 hashCode
    • 1bit CMS 空闲
    • 4bit 对象分代年龄
    • 2bit 锁标志位为 01
  • 偏向锁

    • 54bit 线程ID(偏向锁的线程ID)
    • 2bit Epoch
    • 1bit CMS 空闲
    • 4bit 对象分代年龄
    • 1bit 是否偏向锁(1)
    • 2bit 锁标志位为 01
  • 轻量级锁

    • 56bit 指向栈中锁记录的指针
    • 1bit CMS 空闲
    • 4bit 对象分代年龄
    • 2bit 锁标志位为 00
  • 重量级锁

    • 56bit 指向重量级锁的指针
    • 1bit CMS 空闲
    • 4bit 对象分代年龄
    • 2bit 锁标志位为 10
  • GC标志

    • 锁标志位为 11

3.2.2 锁定类时的说明

上面看到的是对象的内存结构,那么,当我们锁定一个Class时,需要用到这个吗?

Class<?> clazz = MyClass.class;

这个 Class 对象就是用于描述类的元数据结构。

当使用 synchronized 锁定类时,实际上是锁定了这个 Class 对象。例如:

public class MyClass {
    public static synchronized void synchronizedClassMethod() {
        // 锁定的是 MyClass.class 对象
    }

    public void synchronizedInstanceMethod() {
        synchronized (MyClass.class) {
            // 锁定的也是 MyClass.class 对象
        }
    }
}

在上述代码中,MyClass.class 对象表示类 MyClassClass 实例clazz

当你在同步静态方法或同步代码块中使用 MyClass.class 作为锁时,所有试图进入该同步区域的线程都必须先获得该 Class 对象的锁。

所以,没啥特殊的,是一样的:

  • 锁定 Class 对象与锁定实例对象类似,锁的信息也存储在对象头中。

  • 锁定 Class 对象的锁信息会存储在 Class 对象的头部,就像锁定普通对象时锁信息存储在普通对象的头部一样。

4.锁的升级

4.1 CAS操作

CAS (Compare-And-Swap) 是一种原子操作,用于实现无锁并发的数据结构和算法。

Compare-And-Swap:比较 和 交换

CAS的三个操作数:

  • 内存位置
  • 预期数值
  • 新值

CAS的实现逻辑是将内存位置处的实际值与预期的新值进行比较。

  • 比较:比较某个内存位置的当前值是否等于预期值。
  • 交换
    • 如果相等:则将该内存位置的值更新为新值
    • 如果不相等:则不做任何操作

4.1.1 实现过程

还记得前面我们在这篇文章:Java-线程-并发中提到的一个点吗?

image-20240515204520568

你看,这里说到了,我们有个疑惑点。

哇靠,线程1你不知道线程2它都把值更新了吗?

CAS呢,似乎就是这个思路。你看,我在操作的时候记录下预期值,如果预期值对不上,起码我就知道,有敌人。

以之前存在的0+1问题为例,CAS的目的,是能够确保我们+1这个操作是原子性的,你可以先回顾下上面的文章。

假设我刚读取到值0,还没准备+1就交出cpu了,这个时候线程2将值改成了1,我们再执行,就能发现值从0变成了1,就不能再继续操作。

嗯哼,直到下次我们逻辑吻合,才能进行+1操作,从整体实现了+1这个操作的原子性。

image-20240515205612793


在Java中,CAS通常通过compareAndSet方法来实现。

这个方法在java.util.concurrent.atomic包中的多个类中都有使用,如AtomicIntegerAtomicLongAtomicReference等。

AtomicInteger为例:

    public final int getAndIncrement() {
        return unsafe.getAndAddInt(this, valueOffset, 1);
    }

调用unsafe类中getAndAddInt()方法

    public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
    }

最后,你总能找到compareAndSwapXX,如上方的compareAndSwapInt。

	// var1:要更新的对象。
	// var2:对象中需要更新字段的偏移量。
	// var4:预期值(当前值)。
	// var5:要更新的新值。
	public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
  • 如果相等,返回true。
  • 如果不相等,返回false。

该方法结合其他方法,实现线程安全的操作。

image-20240515201319149

4.1.2 ABA问题

CAS固然简单,但是存在一个ABA问题:

如果一个变量在读取后被修改,再次变回原值,CAS无法检测到变化。

你可能疑惑,变回原值有什么问题?反正我的值没变,我修改了没啥影响啊?

注意,此处的关键点是,我们本意是当前操作是原子性的,ABA的存在,打破了这个整体的原子性,导致其中夹杂了一段额外的逻辑,进而导致数据的混乱。

实际上呢,我们还能再举例下:

比如,我们在线编辑钉钉的表格,里面有上次统计的人天单价1000/天,最近人天单价涨价了,最新的是1300/天,我们要把新厂商单价记录在后面。

此时,领导突然叫我们有事,我怕有人改我数据。

  • 加锁的视角:你可以叫个人在电脑面前守着,禁止别人乱动你的电脑。
  • CAS的视角:我回来的时候,一看上次的1000/天,被人改成了1500/天。md,我走的时候明明看到旧的是1000,肯定有人动我电脑了。
    • ABA问题:我回来的时候,看到的还是1000/天。此时,你以为没人动你电脑。其实中途来了个人,把你的D盘格式化啦,过了会你终于发现了,tmd谁删我东西了?

4.1.3 CAS优缺点

  • 优点

    • 无锁操作:避免了使用锁机制带来的上下文切换和阻塞。
    • 高效:适用于高并发情况下的变量更新操作。
  • 缺点

    • ABA问题:如果一个变量在读取后被修改,再次变回原值,CAS无法检测到变化。
    • 开销:在高竞争情况下,CAS操作可能需要多次重试(while在循环等待),带来性能开销。

4.2 锁的分类(Java对象的锁状态)

基于前面的类结构,我们可以知道这么几种锁:

  • 偏向锁
  • 轻量级锁(自旋锁)
  • 重量级锁

使用synchronized时,一定条件下会触发由偏向锁 -> 轻量级锁 -> 重量级锁的变换,我们将这个过程称为锁的升级

注意,是使用这个synchronized的时候触发。你都没有同步要求,我干嘛拿锁?

Java对象的锁状态分为以下几种:

序号 锁类型 场景 实现 位置 优点 缺点
0 无锁 - 对象没有被任何线程持有锁,所有线程都能自由访问对象,无需任何同步机制。 - - -
1 偏向锁 同一线程多次进入同步块时(没有竞争) 在对象头中记录偏向线程的ID,如果同一线程再次进入同步块,直接进入,无需CAS操作。 用户态 锁开销极低,适用于无竞争的情况 一旦有其他线程竞争,需等待偏向撤销,适用于线程间少量竞争的场景。
2 轻量级锁 锁定时间短,发生竞争时使用 线程尝试通过CAS操作获取锁,如果失败则自旋等待一段时间。 用户态 避免了线程切换的开销,自旋等待有机会快速获得锁。 自旋等待会消耗CPU时间,不适用于长时间持锁的情况。
3 重量级锁 线程竞争严重或持锁时间较长 通过操作系统的互斥量(Mutex)实现,线程竞争锁失败时会被挂起。 内核态 线程挂起等待锁释放,不消耗CPU时间 线程挂起和恢复的开销较大,适用于长时间持锁的情况。

4.1.1 偏向锁

在绝大多数情况下,同一线程多次进入同步块时没有竞争。

偏向锁会偏向第一个获得锁的线程,将锁标记为偏向该线程。

如果同一线程再次进入同步块,无需任何同步操作,直接进入,提高了性能。

4.1.2 轻量级锁(自旋锁)

轻量级锁在发生竞争时使用。

线程尝试获取锁时,会自旋一段时间,即忙等待,看看锁是否会被释放。

自旋锁适用于锁定时间很短的场景,因为它避免了线程切换的开销。

如果自旋一段时间后仍未获取到锁,则升级为重量级锁。

自旋就是循环等待,没那么高级。简单点的实现可以来个for循环,比如:

  • 客服系统,我们需要依次记录各个状态的数据,呼叫动作开始了后,才有接听/拒绝的动作数据。
    • 呼叫 -> 接听,2个状态的数据。
    • 现在,我们先收到了接听的报文数据,没有收到呼叫的数据,奇了怪了,等等吧。
public class My {

    private int num = 0;

    public static void main(String[] args) {
        new My().demo1();
    }

    public void demo1() {
        for (int i = 0; i < 10; i++) {
            num = num + 1;
            System.out.printf("当前次数: %d,num: %d\n", i, num);
            // num==5等同于收到呼叫报文这个判断条件
            if (num == 5) {
                // 其他处理
                System.out.println("接收到数据...");
                break;
            }
        }
    }
}

这个时候,我们尝试循环等待10次,只要满足了条件跳出即可。

image-20240515222313070

4.1.3 重量级锁

锁的管理从用户态移到了操作系统内核,使用操作系统的互斥机制(如Mutex)来实现,无法获取锁的线程会被挂起。

重量级锁是最传统的锁机制,在线程竞争严重或持锁时间较长时使用。

当线程无法获取到锁时,操作系统会将线程挂起,等待锁释放。

重量级锁通过操作系统的同步原语实现,切换线程代价较高,但在长时间持锁情况下比自旋更有效。

4.3 升级过程

4.3.1 锁竞争检测

image-20240515212910351

还记得这张对象结构嘛,你看,图里有个位置记录了偏向锁当前的线程id。

  • 当一个线程第一次获取对象锁时,对象头和栈帧中的锁记录会存储线程ID,锁进入偏向状态。
    • 无竞争:如果之后该线程再次获取该锁,无需任何同步操作,直接进入临界区。
    • 触发竞争:如果另一个线程尝试获取已偏向其他线程的锁,JVM会检测到竞争。

即:我靠,我这里记录的是线程1,咋来了个线程2也想获取啊???

4.3.2 偏向锁 -> 自旋锁

关键点:CAS将偏向线程id改为新线程id

当另一个线程尝试获取已经偏向某个线程的偏向锁时,JVM会检测到锁竞争

以线程1和线程2为例:

  • 线程1获取偏向锁:对象A进入偏向锁状态,锁偏向于线程1。

  • 线程2尝试获取锁,触发锁竞争。

    • JVM尝试通过CAS操作将偏向线程ID更新为线程2的ID。

      • CAS操作成功

        • 偏向锁重新偏向线程2。
        • 对象xx的锁状态仍为偏向锁,但偏向线程2。
      • CAS操作失败

        • 竞争激烈,锁状态被其他线程改变或正在改变。
        • 偏向锁撤销,升级为轻量级锁,竞争线程进入自旋状态。

撤销偏向锁需要将锁状态升级为轻量级锁,这个过程涉及到STW(Stop-The-World)操作,即暂停所有正在执行的线程。

这里的竞争激烈如何理解:

CAS操作失败的本质,是说明了一定有敌人,哈,难不成还能是我自己改的吗?

此时,肯定是有另一个线程给我改了,我就这么小小小会就触发CAS失败了,这说明很激烈啊。

JVM在想,这个时机,我到底要不要放2技能触发锁的升级到自旋啊!!!

如果不激烈,那我CAS没人抢,自然就成功了,竞争状态十分,哦不对,你两不紧不慢的,都不算竞争。

我不care偏向谁,反正我id存着的,你们随便用,又不会出问题。

我只关心要不要放2技能解决你们打架的问题,小小场面,无需升级。

4.3.3 自旋锁 -> 重量级锁

关键点:CAS失败后,触发自旋等待(忙等待),在循环中不断尝试获取锁,而不进入阻塞状态。

还是线程1和2为例:

(对象xx升级到了自旋锁)此时线程2在CAS失败后,线程会执行循环等待,尝试通过CAS操作获取锁。

  • 如果线程1很快释放锁,线程2在自旋次数内成功获取锁,进入同步代码中进行执行。
  • 如果线程1长时间持有锁,线程2在自旋次数用尽后,自旋锁升级为重量级锁。
4.3.2.1 相关参数

-XX:PreBlockSpin

  • 这个参数设置了在锁竞争激烈时,每个线程在进入阻塞之前自旋的次数。自旋是一种忙等待的方式,线程在获取锁之前会主动让出CPU时间片。
  • 默认值通常是10次,具体取决于JVM版本和平台。

5.风险点

6.常见问题

6.1 锁升级后能降级吗?

我觉得这个问题要从锁对象的角度谈起,在单个生命周期类,哪怕竞争状态减小了,锁也是不会降级的。

但是,当一个线程释放了重量级锁后,该锁就会被释放并变为无状态(无锁状态),等待下一个线程来获取。

但是你站在锁这个对象来看,在变成重量级锁后释放后,下次,它又可能从偏向开始一个新的生命周期。

6.2 synchronized是悲观锁还是乐观锁?

悲观锁和乐观锁是一种思想。

悲观锁:都tmd别动,我用完了再说,强制占有。

乐观锁:不一定会有问题,我到时候检查下预期值。例如CAS,例如我们前年提到看电脑发现值被改了的例子。

那么悲观锁的底层,是拿到了这个锁的对象,你才能操作。

基于这个角度,我认为synchronized是悲观锁的思想。

尽管在一些场景下可能会使用 CAS 进行优化,比如我们偏向到自旋的过程。

但是,我觉得,核心目的还是在说,你必须拿到锁了,才能操作,首次升级那里,目的只是在于是否做升级动作,最后还是得等拿到锁了才操作。

5.3 synchronized能中断吗?能获取锁状态吗?

  • 不能中断

    synchronized块在获取锁时会阻塞其他线程,直到当前线程执行完毕并释放锁。

    即,当一个线程进入了一个被synchronized修饰的方法或代码块时,其他竞争线程必须等待这个线程执行完毕,并释放锁之后,才能进入这个方法或代码块。

  • 不能获取状态

    synchronized并没有提供直接的方法来获取锁的状态。

    如果需要知道锁的状态,可以考虑使用Java中的ReentrantLock类,提供了isLocked()isHeldByCurrentThread()等方法来查询锁的状态。

posted @ 2024-05-15 12:35  羊37  阅读(38)  评论(0编辑  收藏  举报