Java多线程:线程同步(3)- synchronized关键字

🍇一、synchronized

1. 介绍

synchronizeJava中的关键字,可以用在实例方法、静态方法、同步代码块。synchronize解决了:原子性、可见性、有序性三个问题,用来保证多线程环境下共享变量的正确性。

🥇原子性:执行被synchronized修饰的方法和代码块,都必须要先获得类或者对象锁,执行完之后再释放锁,中间是不会中断的,这样就保证了原子性。

🥈可见性:执行被synchronized修饰的方法和代码块,一个线程获得了锁,执行完毕之后, 在释放锁之前,会对变量的修改同步回内存中,对其它线程是可见的。

🥉有序性synchronized保证了每个时刻都只有一个线程访问同步代码块或者同步方法,这样就相当于是有序的。

2. 使用示例

用一个示例来展示synchronized的用法,现在有两个线程,对一个变量进行自增10000000次操作,在正确的情况下,最后的结果应该是20000000。但是实际使用过程中可能会出现各种情况。

public class MainDemo extends Thread {
    private static int increment = 0;

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            increment++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();
        // 阻塞主线程,直到t1和t2运行完毕
        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

2.1. 修饰普通方法

synchronized修饰普通方法只需要在方法上加上synchronized即可。synchronized修饰的方法,如果子类重写了这个方法,子类也必须加上synchronized关键字才能达到线程同步的效果。

public class MainDemo extends Thread {
    private static int increment = 0;

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            incrementMethod();
        }
    }

    public synchronized void incrementMethod() {
        increment++;
    }

    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

2.2. 修饰静态方法

synchronized作用于静态方法时,和实例方法类似,只需要在静态方法上面加上synchronized关键字即可。

public class MainDemo extends Thread {
    private static int increment = 0;

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            incrementMethod();
        }
    }

    public static synchronized void incrementMethod() {
        increment++;
    }

    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

2.3. 修饰同步代码块

修饰同步代码块可以使用:类对象和实例对象,但是要保证唯一性,多个线程使用的对象要是同一个对象。唯一性的意思就是说下面的objectLock必须是同一个对象,如果每个线程都新建一个对象,那么就达不到保证线程安全的效果。

public class MainDemo extends Thread {
    private static int increment = 0;

    private static Object objectLock = new Object();

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            synchronized (objectLock) {
                increment++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

3. 虚拟机标记

synchronized可以保证线程安全,那么虚拟机是如何识别synchronized的呢?

Java虚拟机层面标记synchronized修饰的代码有两种方式:

🥇ACC_SYNCHRONIZED标识位

🥈monitorentermonitorexit指令

3.1 同步代码块

synchronized修饰同步代码块的时候。

public class MainDemo extends Thread {
    private static int increment = 0;

    private static Object objectLock = new Object();

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            synchronized (objectLock) {
                increment++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

我们先编译这个类,然后再反编译反编译。

// 编译
javac MainDemo.java
// 反编译
javap -v MainDemo.class

反编译之后输出如下内容,我们可以看到在13行和23行有monitorentermonitorexit两个指令,其中monitorenter指令指向同步代码块的开始位置,monitorexit指令则指明同步代码块的结束位置,当执行monitorenter指令的时候需要去获取对象锁,执行monitorexit的时候释放对象锁。

  public void run();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_0
         1: istore_1
         2: iload_1
         3: ldc           #2                  // int 10000000
         5: if_icmpge     38
         8: getstatic     #3                  // Field objectLock:Ljava/lang/Object;
        11: dup
        12: astore_2
        13: monitorenter
        14: getstatic     #4                  // Field increment:I
        17: iconst_1
        18: iadd
        19: putstatic     #4                  // Field increment:I
        22: aload_2
        23: monitorexit
        24: goto          32
        27: astore_3
        28: aload_2
        29: monitorexit
        30: aload_3
        31: athrow
        32: iinc          1, 1
        35: goto          2
        38: return

3.2. 同步方法

synchronized修饰实例方法或者静态方法的时候。

public class MainDemo extends Thread {
    private static int increment = 0;

    @Override
    public void run () {
        for (int i = 0; i < 10000000; i++) {
            incrementMethod();
        }
    }

    public synchronized void incrementMethod() {
        increment++;
    }

    public static void main(String[] args) throws InterruptedException {
        MainDemo mainDemo = new MainDemo();
        Thread t1 = new Thread(mainDemo);
        Thread t2 = new Thread(mainDemo);
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(increment);
    }
}

我们先编译这个类,然后再反编译反编译。

// 编译
javac MainDemo.java
// 反编译
javap -v MainDemo.class

无论是实例方法还是静态方法,实现都是通过ACC_SYNCHRONIZED标识,反编译之后可以看到在方法上有ACC_SYNCHRONIZED标识,表明这是一个同步方法,线程执行这个方法的时候都会先去获取对象锁,方法执行完毕之后会释放对象锁。

public synchronized void incrementMethod();
descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
stack=2, locals=1, args_size=1
    0: getstatic     #4                  // Field increment:I
    3: iconst_1
    4: iadd
    5: putstatic     #4                  // Field increment:I
    8: return
    LineNumberTable:
line 13: 0
    line 14: 8

🍉二、底层实现

1. 对象结构

实例对象在内存中的结构分了三个部分:对象头、实例数据、对齐填充。对象头又包含了:标记字段Mark Word、类型指针KlassPointer、长度Length field三个部分。

  • 对象头:存储了锁状态标志、线程持有的锁等标志。

    • 标记字段Mark Word:用于存储对象自身的运行时数据,他是经过轻量级锁和偏向锁的关键
    • 类型指针KlassPointer:是对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例
    • 长度Length field:如果是数组对象,对象头还包含了数组长度。
  • 实例数据:对象真正存储的有效信息,存放类的属性数据信息。

  • 对齐填充:对齐填充不是必须存在的,仅仅时让对象的长度达到8字节的整数倍,其中一个原因就是为了不让对象数据跨缓存行,用空间换时间。

在这里插入图片描述

2. Mark Word结构

Mark word主要用于存储对象在运行时自身的一些数据,比如GC信息、锁信息等。在64位虚拟机中Mark Word的结构如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lORGkcDC-1689059958951)在这里插入图片描述

3. 查看对象头结构数据

3.1. 引入相关的jar包

首先导入相关的包

<dependency>
   <groupId>org.openjdk.jol</groupId>
   <artifactId>jol-core</artifactId>
   <version>0.9</version>
</dependency>

3.2. 示例代码

public class SynDemo {
    public static void main(String[] args) {
        Object object = new Object();
        System.out.println(Integer.toString(object.hashCode(), 2));
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
    }
}

3.3. 对象头数据

hashcode: 10111101001111100111011000010
java.lang.Object object internals:
OFFSET SIZE  TYPE DESCRIPTION                               VALUE
  0     4        (object header)  01 c2 ce a7 (00000001 11000010 11001110 10100111) 
  4     4        (object header)  17 00 00 00 (00010111 00000000 00000000 00000000)
  8     4        (object header)  e5 01 00 f8 (11100101 00000001 00000000 11111000) 
 12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

3.4. Mark Word分析

从对象头的结构可以得知,Mark word的大小为8个字节,那么我们输出的对象头数据的前两行就是``Mark word的内容,其中value就是Mark word的数据。同时我们也输出了对象头的hashcode`值,用于对比。

OFFSET SIZE  TYPE DESCRIPTION                               VALUE
  0     4        (object header)  01 c2 ce a7 (00000001 11000010 11001110 10100111) 
  4     4        (object header)  17 00 00 00 (00010111 00000000 00000000 00000000)

Mark word值分了两种格式输出,前面是十六进制的,后面是二进制的,我们输出的hashcode值是:10111101001111100111011000010,对象头的hashcode是31位的,补全之后的hashcode值:0010111101001111100111011000010 从二进制中好像是找不到对应的数据,下面做一个处理。

在这里插入图片描述

如下图我们将上面的二进制按照倒叙排列,图中红色方框内的数据就和hashcode值完全对应上了,再结合对象头Mark word结构,最后两位绿框就是锁的标识位。

在这里插入图片描述

4. 如何实现

synchronized是借助Java对象头来实现的,通过对象头的介绍,可以知道,对象头的Mark word里的数据是在变化的,不同的数据表示了不同类型的锁,而synchronized就是通过获取这些锁来实现线程安全的。

前面我们说了当我们使用synchronized来保证线程安全的时候,虚拟机在编译代码的时候,会添加标记:ACC_SYNCHRONIZEDmonitorentermonitorexit指令。

当虚拟机执行代码的时候,如果发现了这些标记,那么就会让线程去获取对象锁,也就是去修改对象头的数据,只有获取到锁的线程才能继续执行代码,其它的线程则需要等待,直到获取锁的线程释放锁。

🍏三、锁升级过程

在1.6之前,synchronized只有重量级锁,在1.6版本对synchronized锁进行了优化,有了偏向锁,轻量级锁。

由此锁升级有四种状态:无锁,偏向锁,轻量级锁,重量级锁。锁升级是不可逆的,只能升级不能降级。

锁的升级是通过对象头的Mark word的数据变化来完成的,数据会根据锁变化而变化。

1. 无锁

1.1 Mark Word结构

无锁就是没有线程来抢站对象头这个时候的Mark word的数据如下:

锁状态25bit31bit1bit4bit1bit 是否偏向锁2bit 锁标识位
无锁unused对象的hashcodeunused分代年龄001

1.2. 对象头结构数据

public class SynDemo {
    public static void main(String[] args) {
        Object object = new Object();
        System.out.println(Integer.toString(object.hashCode(), 2));
        System.out.println(ClassLayout.parseInstance(object).toPrintable());
    }
}

可以看到无锁的时候对象头最后八位的数据是00000001,标识锁的两位是01。

hashcode: 10111101001111100111011000010
java.lang.Object object internals:
OFFSET SIZE  TYPE DESCRIPTION                               VALUE
  0     4        (object header)  01 c2 ce a7 (00000001 11000010 11001110 10100111) 
  4     4        (object header)  17 00 00 00 (00010111 00000000 00000000 00000000)
  8     4        (object header)  e5 01 00 f8 (11100101 00000001 00000000 11111000) 
 12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

2. 偏向锁

2.1 Mark Word结构

偏向锁的Mark word锁标志位和无锁一样是01,是否偏向锁是1

锁状态54bit2bit4bit1bit 是否偏向锁2bit 锁标识位
无锁unused对象的hashcode分代年龄101

2.2 什么是偏向锁

偏向锁主要是来优化同一个线程多次获取同一个锁的,有时候线程t在执行同步代码的时候先去获取锁,执行完了之后不会释放锁,然后第二次线程t第二次执行同步代码的时候先去获取锁,发现Mark word的线程ID就是它,就不需要重新加锁。

在JDK1.6之后是默认开启偏向锁的,但是我们在使用的时候是绕过偏向锁了直接进入轻量级锁,这是因为虽然默认开启了偏向锁,但是开启是有延迟的,大概是4s钟,也即是程序刚启动创建的对象是不会开启偏向锁的,4秒之后创建的对象才会开启,可以通过JVM参数来设置延迟时间。

//关闭延迟开启偏向锁
-XX:BiasedLockingStartupDelay=0
//禁止偏向锁
-XX:-UseBiasedLocking 
//启用偏向锁
-XX:+UseBiasedLocking 

在JDK15中偏向锁已经被标记为Deprecate

2.3 加锁过程

线程获取偏向锁的过程,当线程执行被synchronized修饰的同步代码块的时候

1.检查锁标志位是否是01

2.检查是否是偏向锁

3.如果不是偏向锁,直接通过CAS替换Mark word的线程ID为当前线程ID,并修改是否偏向锁为1

4.如果是偏向锁,检查Mark word的线程ID是否是当前线程的ID,如果是的话直接就执行同步代码块,如果不是当前线程的线程ID也是通过CAS替换Mark word的线程ID为当前线程ID

5.CAS成功之后便执行同步代码

2.4 锁升级过程

上面我们说在加锁的过程中都是通过CAS操作替换Mark word的线程ID为当前线程的ID,如果CAS失败了就可能会升级为轻量级锁

1.当CAS失败的时候,原持有偏向锁到达线程安全点的时候

2.检查原持有偏向锁的线程的线程状态

3.如果原持有偏向锁的线程还没有退出同步代码块就升级为轻量级锁,并且仍然由原线程持有轻量级锁

4.如果原持有偏向锁的线程已经退出同步代码块了,偏向锁撤销Mark word的线程ID更新为空,是否偏向改为0

5.如果原线程不竞争锁,则偏向锁偏向后来的线程,如果原线程要竞争锁,则升级为轻量级锁

如果调用了对象的hashcode方法或者执行了wait和 notify方法,锁升级为重量级锁。

2.2 对象头结构数据

设置睡眠时间为5秒,这样才会进入偏向锁,只有一个线程来竞争锁,所以会转向偏向锁

public class SynDemo  {
    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(5000);
        SynDemo synDemo = new SynDemo();
        synchronized (synDemo) {
            System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
        }
    }
}

这里只有一个线程在竞争锁,所以锁就是偏向锁,锁标志位是01,是否偏向是1。

 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
  0     4        (object header)       05 88 80 54 (00000101 10001000 10000000 01010100) 
  4     4        (object header)       c2 7f 00 00 (11000010 01111111 00000000 00000000) 
  8     4        (object header)       05 c1 00 f8 (00000101 11000001 00000000 11111000) 

3. 轻量级锁

3.1 Mark Word结构

锁状态62bit2bit
轻量级锁指向线程栈中的Lock Record指针00

3.2什么是轻量级锁

当偏向锁,被另外的线程访问的时候,偏向锁就会升级为轻量级锁,其它线程会通过自旋的形式尝试获取锁,不会阻塞线程,从而提高了性能。

3.3 加锁过程

1.在当前线程的栈帧中建立一个名为锁记录Lock Record空间

2.拷贝对象头的Mark word到锁记录空间,这个拷贝的过程官方称为Displaced Mark Word

3.使用CAS操作把Mark Word中的指针指向线程的锁记录空间,更新锁标志位为00

4.当线程持有偏向锁并且偏向锁升级为轻量级锁,

5.如果线程是持有偏向锁升级为轻量级锁那么不用通过CAS获取锁,而是直接持有锁

3.4 释放锁

当线程执行完同步代码块的时候,就会释放锁

1.用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来

2.如果替换成功,同步过程就完成了

3.如果替换不成功,说明有其它线程尝试获取该锁,就要在释放锁的同时,唤醒被挂起的线程

3.5 锁升级过程

当线程通过CAS获取轻量级锁,如果CAS的次数过多,没有获取到轻量级锁,那么锁就会升级为重量级锁。

除此之外一个线程在持有锁,一个在自旋,又有第三个线程来竞争锁,轻量级锁升级为重量级锁。

次数

3.6 对象头结构数据

public class SynDemo  {
    public static void main(String[] args) throws InterruptedException {
        SynDemo synDemo = new SynDemo();
        Thread t1 = new Thread(() -> {
            synchronized (synDemo) {
                System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
            }
        });
        t1.start();
    }
}
OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
  0     4        (object header)      08 49 20 11 (00001000 01001001 00100000 00010001)
  4     4        (object header)      00 70 00 00 (00000000 01110000 00000000 00000000)
  8     4        (object header)      05 c1 00 f8 (00000101 11000001 00000000 11111000)

4. 重量级锁

4.1 Mark word结构

锁状态62bit2bit
轻量级锁指向互斥量的指针10

4.2 什么是重量级锁

在Java1.6之前synchronized的实现只能通过重量级锁实现,在1.6之后当轻量级锁自旋一定次数后还是没有获取到锁,此时锁就会升级为重量级锁。

重量级锁在竞争锁的时候,除了持有锁的线程,其它竞争锁的线程都会在等待队列中,防止不必要的开销。

4.3 对象头数据

public class SynDemo  {
    public static void main(String[] args) throws InterruptedException {
        SynDemo synDemo = new SynDemo();
       Thread t1 = new Thread(() -> {
           synchronized (synDemo){
               System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
               try {
                   Thread.sleep(2000);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
       });

        Thread t2 = new Thread(() -> {
            synchronized (synDemo){

                System.out.println(ClassLayout.parseInstance(synDemo).toPrintable());
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        t1.start();
        t2.start();
    }
}
 OFFSET  SIZE   TYPE DESCRIPTION                VALUE
   0     4      (object header)       3a f2 6f 1c (00111010 11110010 01101111 00011100) 
   4     4      (object header)       00 00 00 00 (00000000 00000000 00000000 00000000) 
   8     4      (object header)       05 c1 00 f8 (00000101 11000001 00000000 11111000)

4.3 重量级锁实现原理

重量级锁是借助Monitor来实现的,在Java虚拟机中Monitor机制是基于C++实现的,每一个Monitor都有一个ObjectMonitor对象。当锁升级为重量级锁的时候Mark word中的指针就指向ObjectMonitor对象地址。通过ObjectMonitor就可以实现互斥访问同步代码。
在这里插入图片描述

ObjectMonitor的部分变量,用于存储锁竞争过程中的一些值。

ObjectMonitor() {
    // 处于wait状态的线程,会被加入到_WaitSet
    ObjectWaiter * volatile _WaitSet;
    //处于等待锁block状态的线程,会被加入到该列表
    ObjectWaiter * volatile _EntryList;
    // 指向持有ObjectMonitor对象的线程
    void* volatile _owner;
    // _header是一个markOop类型,markOop就是对象头中的Mark Word
    volatile markOop _header;
    // 抢占该锁的线程数,约等于WaitSet.size + EntryList.size
    volatile intptr_t _count;
    // 等待线程数
  	volatile intptr_t _waiters;
    // 锁的重入次数
    volatile intptr_ _recursions;
    // 监视器锁寄生的对象,锁是寄托存储于对象中
    void* volatile  _object;
    // 操作WaitSet链表的锁
    volatile int _WaitSetLock;
    // 嵌套加锁次数,最外层锁的_recursions属性为0
    volatile intptr_t  _recursions;
    // 多线程竞争锁进入时的单向链表
    ObjectWaiter * volatile _cxq;
  }

objectMoniotr源码解析和重量级锁底层实现原理参考:Java多线程:objectMonitor源码解析(4)

4.4 重量级锁加锁释放锁过程

当线程获取Monitor锁时,首先线程会被加入到_EntryList队列当中,当某个线程获取到对象的monitor锁后将ObjectMonitor中的_owner变量设置为当前线程,同时ObjectMonitor中的计数器_count加1即获得锁对象。

若持有monitor的线程调用wait()方法,将释放当前持有的monitor_owner变量恢复为null_count自减1,同时该线程进入_WaitSet集合中等待被唤醒。若当前线程执行执行完毕也将释放monitor,以便其它线程线程进入获取monitor

1.没有线程来竞争锁的时候,ObjectMonitor_ownernull

在这里插入图片描述

2.现在有三个线程来获取对象锁,线程首先被封装成ObjectWait对象,然后进入到_EntryList队列中,竞争对象锁。

在这里插入图片描述

3.当t1获取到对象锁的时候,ObjectMonitor对象的_owner指向t1,_count数量加1。

在这里插入图片描述

4.执行完毕之后释放锁,然后又进行下一轮锁竞争。

在这里插入图片描述

🍓四、锁优化

1. 适应性自旋锁

在轻量级锁中,当线程竞争不到锁的时候,是通过CAS自旋一直去获取尝试获取锁,这样就不用放弃CPU的执行时间片

这一过程有一个缺点就是如果持有锁的线程运行的时间很长的话,那么自旋的线程一直占用CPU又不能执行就很浪费资源,可以通过JVM参数设置自旋的次数,如果超过这个次数还没有获取到锁就升级为重量级锁,挂起当前线程。

// 设置自旋次数上限
-XX:PreBlockSpin=10

为了优化自旋占用CPU执行时间片,JVM引入了适应性自旋,适应性自旋会根据前面获取锁的线程自旋的次数来自动调整。

如果前面的线程通过自选获取到了锁,那么JVM会自动增加自旋上限次数。

如果前面的线程自旋很少能获取到锁,那么就会挂起当前线程,升级为重量级锁。

2. 锁消除

2.1 什么是锁消除

Java代码在编译的时候,消除不可能存在共享资源竞争的锁,通过这种方式消除没必要的锁,减少无意义的请求锁时间。

锁消除的依据JIT编译的时候进行逃逸分析,如果当前对象作用域只在方法的内部,那么JVM就认为这个锁可以消除。

// 关闭锁消除
-XX:-EliminateLocks
// 开启逃逸分析
-XX:+DoEscapeAnalysis
// 开启锁消除
-XX:+EliminateLocks

2.2 代码示例

虽然Demo类的synMethod是一个同步方法,但是demo类是一个局部变量,根据逃逸分析该对象只会在栈上分配
属于线程私有的,所以会自动消除锁。

public class Demo {
  public static void main(String[] args) {
    Demo demo = new Demo();
    demo.synMethod();
  }
  private synchronized void synMethod() {
    System.out.println("同步方法");
  }
}

3. 锁粗化

3.1 什么是锁粗化

锁粗化就是把锁的范围加大到整个同步代码的外部,这样能降低频繁的获取锁,从而提升性能。

如下面这段代码,在循环体内加锁,可以把锁加到循环体的外部,这样减少了加锁的次数,提升了性能

3.2 代码示例

for(int i = 0; i < 10; i++){
    synchronized(lock){
    }
}
synchronized(lock) {
  for (int i = 0; i < 10; i++) {
    
  }
}

参考资料

https://juejin.cn/post/7232524757526429756

锁升级图示

  • 32
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值