synchronized关键字

本文详细介绍了Java中的synchronized关键字,包括其作用、使用方式、实例方法与静态方法的区别、同步代码块的使用,以及底层原理和锁升级机制。通过实例分析了synchronized如何保证线程安全,并探讨了可重入性、等待与唤醒操作。最后,讨论了synchronized的优化策略,如偏向锁和轻量级锁。
摘要由CSDN通过智能技术生成

synchronized关键字

JDK版本:1.8

1.synchronized的作用

​ 在并发编程中造成线程安全问题的诱因主要有两点:1、存在共享数据也称临界资源,2、多个线程共同操作共享数据。为了解决这个问题,Java语言中提供了synchronized关键字,当存在多个线程操作共享数据时,需要保证同一时刻有且仅有一个线程在操作共享数据,其它线程必须等待当前正在访问的线程处理完数据后在进行,这种方式称为互斥锁,即能够达到互斥访问目的的锁。当一个共享数据被当前正在访问的线程加上互斥锁之后,在同一时刻,其它线程只能处于等待状态,直到当前正在访问的线程执行完毕释放该锁。

synchronized关键字可以保证在同一时刻,只有一个线程可以访问某个方法或者某个代码块,同时synchronized关键字可以保证一个线程的变化(主要是共享数据的变化)对其它线程可见,所以synchronized关键字完全可以替代volatile关键字


2.synchronized关键字的三种使用方式

Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:

  • 普通同步方法(实例方法),锁的是当前实例对象,访问普通同步方法之前要获得当前实例的锁。
  • 静态同步方法,锁的是当前类的Class对象,访问静态同步方法之前要获得当前对象的锁。
  • 同步代码块,锁的是括号中的对象,对给定对象加锁,访问同步代码块之前要获得加锁对象的锁。

3.synchronized修饰实例方法

public class CountSync implements Callable<Integer> {

    /**
     * 共享变量, 临界资源
     */
    static int count = 0;

    /**
     * synchronized 关键字修饰实例方法
     */
    private synchronized void increase() {
        count++;
    }

    @Override
    public Integer call() throws Exception {
        for (int i = 0; i < 100; i++) {
            increase();
        }
        return count;
    }

    public static void main(String[] args) {
        CountSync countSync = new CountSync();
        FutureTask<Integer> taskOne = new FutureTask<>(countSync);
        FutureTask<Integer> taskTwo = new FutureTask<>(countSync);
        Thread threadOne = new Thread(taskOne);
        Thread threadTwo = new Thread(taskTwo);
        threadOne.start();
        threadTwo.start();
        try {
            Integer resultOne = taskOne.get();
            System.out.println("result = " + resultOne);
            Integer resultTwo = taskTwo.get();
            System.out.println("result1 = " + resultTwo);
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
    }

}

输出结果:

resultOne = 100
resultTwo = 200

Process finished with exit code 0

​ 同时开启两个线程操作同一个共享资源,代码中的count++并不是原子性操作,该操作是先读取值,然后写回++后的值。如果线程threadTwothreadOne读取旧值和写回新值期间读取count的域值,那么threadTwothreadOne读取的就是同一个值,这样就会并行执行对象相同的值+1的操作,这也就导致了线程安全问题,因此对于操作临界变量的increase()方法必须使用synchronized关键字修饰进行同步处理,以保证对临界资源的操作是线程安全的。

​ 当使用synchronized关键字修饰实例方法时,这种情况下加锁的是CountSync的实例对象。在Java中一个对象只有一把锁,所以当一个线程正在访问某一个实例对象的synchronized实例方法时,那么其它线程不能同时访问这个实例对象的其它synchronized实例方法,因为当一个线程获取了该实例对象的锁之后,在这个线程释放掉这个对象锁之前其它线程都无法获取该对象的锁,所以其它线程无法同时访问该实例对象的其它synchronized实例方法。但是其它线程还是可以访问该实例对象的非synchronized实例方法。

​ 因为在Java中每一个对象都可以作为锁,所以当一个线程访问类A的实例对象ObjectOnesynchronized实例方法时,另外一个线程可以同时访问类A的实例对象Objectwo的被synchronized修饰的实例方法。这样是允许的,因为类A的两个实例对象ObjectOneObjectTwo其实在JVM堆内存中是两个独立的实例对象,在对象头中的Mark Word中都拥有各自的对象锁,所以当多线程同时访问这两个实例对象时,是可以同时访问被synchronized修饰的实例方法的,但是如果两个线程操作的都是共享数据,那么线程安全问题就无法保证了。具体请见如下代码:

public class NumberSync implements Runnable {

    /**
     * 类变量
     */
    static int count = 0;

    /**
     * 对象变量
     */
    private int age = 0;

    private synchronized void increaseCount() {
        count++;
    }

    private synchronized void increaseAge() {
        age++;
    }

    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            increaseCount();
        }
        for (int i = 0; i < 20; i++) {
            increaseAge();
        }
    }

    public int getAge() {
        return this.age;
    }

    public static void main(String[] args) throws InterruptedException {
        NumberSync numberSyncOne = new NumberSync();
        NumberSync numberSyncTwo = new NumberSync();
        Thread threadOne = new Thread(numberSyncOne);
        Thread threadTwo = new Thread(numberSyncTwo);

        threadOne.start();
        threadTwo.start();

        threadOne.join();
        threadTwo.join();

        System.out.println("count = " + count);

        int ageOne = numberSyncOne.getAge();
        System.out.println("age one = " + ageOne);

        int ageTwo = numberSyncTwo.getAge();
        System.out.println("age two = " + ageTwo);
    }

}

输出结果:

count = 1975
age one = 20
age two = 20

Process finished with exit code 0

​ 上述代码与前面不同的是创建了两个新实例NumberSync,然后启动两个线程分别对类变量与对象变量进行操作,可以看出对于类变量count出现了线程安全问题,但是对于对象变量age却没有出现线程问题,这也就意味着实例numberSyncOnenumberSyncTwo都拥有各自的实例对象锁,因此当两个线程同时访问各自的age对象变量时,虽然操作age的方法是被synchronized关键字修饰的,但是并不影响线程对它们的访问操作。而当两个线程同时操作类变量count时,由于操作count的是实例方法,但是numberSyncOne实例对象和numberSyncTwo实例对象都拥有各自的对象锁,同一时间两个线程都可访问count这个类变量,所以线程安全是无法保证的。


4.synchronized修饰静态方法

​ 当synchronized关键字修饰静态方法时,锁的就是当前类的Class对象。由于静态成员不属于任何一个实例对象,因此通过Class对象锁可以控制静态成员的并发操作。

​ 需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的static synchronized方法,这是允许的。因为这两者的对象锁是不会互斥的。因为访问static synchronized方法占用的锁是当前类的Class对象,而访问非static synchronized方法占用的锁是当前实例的对象锁,两者并不是同一个锁。所以这种场景下的同时访问是被允许的。

public class CountSync implements Runnable {

    static int count = 0;

    static int age = 0;

    /**
     * synchronized修饰静态方法, 锁是当前Class对象, 也就是
     * CountSync类对于的Class对象
     */
    private static synchronized void increaseCount() {
        count++;
    }

    /**
     * synchronized修饰普通方法, 同时访问实例同步方法和
     * 静态同步方法时由于锁对象不一样不会产生互斥
     */
    public synchronized void increaseAge() {
        age++;
    }

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

    public static void main(String[] args) throws InterruptedException {
        CountSync countSync = new CountSync();
        Thread threadOne = new Thread(countSync);
        Thread threadTwo = new Thread(countSync);
        Thread threadThree = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                countSync.increaseAge();
            }
        }, "Thread Three");

        Thread threadFour = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                countSync.increaseAge();
            }
        }, "Thread Four");

        threadOne.start();
        threadTwo.start();
        threadThree.start();
        threadFour.start();

        threadOne.join();
        threadTwo.join();
        threadThree.join();
        threadFour.join();

        System.out.println("count = " + count);
        System.out.println("age = " + age);
    }

}

输出结果:

count = 2000
age = 2000

Process finished with exit code 0

synchronized关键字修饰静态方法时,锁是类的Class对象,synchronized关键字修饰实例方法时,锁是当前实例对象,所以在访问静态同步方法的同时访问实例同步方法是不会产生互斥的。但是如果静态同步方法和实例同步方法操作的都是同一个临界资源的话可能会产生线程安全问题。


5.synchronized修饰同步代码块

synchronized关键字除了可以修饰实例方法和静态方法之外,还可以修饰代码块,在某些场景下,一个编写的方法体可能会很大,同时存在一些比较耗时的操作,而需要进行同步操作的代码又只有一小部分,如果直接对整个方法进行同步操作,可能就会适得其反,此时可以使用同步代码块的方式对需要进行同步的代码进行包裹,这样就无需对整个方法进行同步操作。

public class CountSync implements Runnable {

    static final CountSync instance = new CountSync();

    static int count = 0;

    @Override
    public void run() {

        // do someThing else
        synchronized (instance) {
            for (int i = 0; i < 1000; i++) {
                count++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread threadOne = new Thread(instance);
        Thread threadTwo = new Thread(instance);
        threadOne.start();
        threadTwo.start();
        threadOne.join();
        threadTwo.join();
        System.out.println("count = " + count);
    }

}

​ 从代码中可以看出,synchronized作用于一个给定的类变量instanceinstanceCountSync的实例对象,即当前实例对象就是锁对象,每当线程要进入synchronized包裹的代码块时就要求当前线程必须持有instance实例对象锁,如果当前有其它线程正持有该对象锁,那么新到的线程就必须等待,这样也就保证了每次只有一个线程访问同步代码块。

除了使用实例对象instance作为对象锁之外,还可以使用this代表当前实例或者当前类的Class对象锁:

this作为当前实例锁:

        synchronized (this) {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }

CountSync.class作为当前类的Class锁:

        synchronized (CountSync.class) {
            for (int i = 0; i < 10000; i++) {
                count++;
            }
        }

6.synchronized底层原理

​ 了解synchronized底层原理之前需要先了解一下Java对象头和Monitor。在JVM中对象内存布局分为三个部分,分别是Header对象头、Instance Data实例数据、Padding对齐填充。

​ 其中对象头是synchronized关键字实现锁的基础,因为synchronized申请锁、上锁、释放锁都与对象头有关。对象头主要由Mark WordClass Metadata Address构成。Mark Word中主要存储对象的HashCodeGC分代年龄、锁状态、线程持有锁、偏向线程ID、偏向时间戳。而Class Meatdata Address是对象的类型指针,该类型指针指向元空间中对象的类元数据,JVM通过该指针确定该对象是那个类的实例。

​ 一般来说,synchronized关键字使用的锁对象是存储在JVM堆内存中该对象的对象头中的Mark Word中的,JVM采用2个字节来存储对象头,如果对象是数组则会分配3个字节。因为如果是数组类型的话,还需要额外在对象头中存储数组对象的长度。

​ 由于对象的头信息是与对象自身定义数据无关的额外存储成本,考虑到JVM的空间使用效率,Mark Word被设计成为一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。

它会根据对象本身的状态复用自己的存储空间。32位的Mark Word默认存储结构如下:

对象头结构

虚拟机位数头对象结构说明
32/64bitMark Word存储对象的HashCodeGC分代年龄、锁状态标志、线程持有锁、偏向线程id、偏向时间戳。
32/64bitClass Metadata Address类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

​ 轻量级锁和偏向锁是JDK 6synchronized关键字优化后新增加的锁状态。这里主要分析一下重量级锁也就是通常说
synchronized的对象锁,锁标识位为10,其中指针指向的是monitor对象,也称为管程或监视器锁的起始地址。Java中每个对象都存在着一个monitor与之关联,对象与其monitor之间的关系存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个monitor被某个线程持有后,它便处于锁定状态。在Java虚拟机HotSpot中,monitor是由ObjectMonitor实现的,其主要数据结构如下位于HotSpot虚拟机源码ObjectMonitor.hpp文件,使用C++实现的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;  //锁计数器
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

​ 在ObjectMonitor中存在两个队列,WaitSet_EntryList,用于保存ObjectWaiter对象列表。其中每个等待锁的线程都会被封装成ObjectWaiter对象,owner指向持有ObjectWaiter对象的线程。单多个线程同时访问一段同步代码时,线程首先会进入到_EntryList集合中,当线程获取到对象的监视器monitor后会进入到_Owner区域并将monitor中的owner变量设置为当前线程,同时monitor中的计数器count1。若线程进入同步代码块之后调用wait()方法, 将会释放当前持有的monitorowner变量恢复为null,同时count1。同时该线程进入WaitSet集合中等待被其它线程唤醒。若当前线程执行完毕也将释放monitor锁并复位变量的值,以便其它线程进入同步代码块获取monitor锁。

monitor对象存在于每个Java对象的对象头中,存储的指针的指向,synchronized锁便是通过这种方式获取锁对象的,所以在Java语言中每一个对象都可以作为锁对象,同时也是notify()notifyAll()wait()等方法位于顶级对象Object中的原因。


7.synchronized关键字下的锁升级

​ 在代码中使用synchronized关键字协调多线程进行并行工作,而底层其实就是使用Object Monitor控制对多个线程的工作过程进行协调。synchronized关键字是传统的悲观锁设计思想的一种实现。但是synchronized关键字的执行过程还涉及到锁的升级过程。synchronized锁有四种状态,分别是:无锁状态偏向锁轻量级锁重量级锁。这几个状态会随着锁竞争状态而向上升级,这种升级是不可逆的,但是偏向锁状态可以被重置为无锁状态。

自旋与自适应自旋

Java中互斥同步对性能最大的影响是线程阻塞的实现,将线程阻塞和恢复的操作都需要CPU从用户态切换到内核态,这个操作是非常消耗性能的,频繁地进行切换将会给JVM的并发性能带来很大的压力。同时JVM开发团队也注意到,在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段很短的时间去挂起和恢复线程并不值得,并且还可能存在刚将一个线程挂起锁就被释放了。此时可以选择让线程稍微等待一下,但并不放弃CPU的时间片,看看持有锁的线程是否很快就会释放锁。为了让线程等待,只需要让线程执行一个忙循环,这就是自旋。

​ 自旋等待并不能代替阻塞,自旋等待虽然避开了CPU切换带来的性能开销,但是它还是会占用CPU的时间片,如果锁被占用的时间非常短,那自旋等待的效果就会非常好,反之如果锁被占用的时间非常长,那么自旋的线程只会白白浪费CPU的资源,而并没有做任何有价值有意义的工作。因此自旋等待必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式将线程挂起。

JDK 6中对自旋锁的优化,引入了自适应自旋。自适应意味着自旋的时间不再是固定的,而是由前一次在同一个锁对象上自旋的时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待相对更长的时间,比如允许自旋100次。而另一方面,如果对于某个锁,自旋很少成功获得锁,在此之后要获得这个锁时有可能直接省略掉自旋等待的过程,以避免浪费处理器资源。这就是自适应自旋。

引入偏向锁的原因

​ 偏向锁是JDK 6中引入的一项优化策略,它的目的是为了消除共享数据在无竞争的情况下的同步原语,以此减少不必要的线程阻塞带来的额外性能开销,偏向锁就是在无竞争的情况下把消除整个同步操作。

​ 偏向锁就是锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其它线程获取,则持有偏向锁的线程将永远不需要再进行同步操作。

​ 在JVM开发者大量研究下发现,大多数场景下是不存在锁竞争机制的,常常是同一个线程多次获得同一个锁,因此,如果每次都竞争锁会增加很多额外的性能开销,为了降低获取锁的代价,JVM开发者便引入了偏向锁。

偏向锁的原理和升级

​ 当一个锁对象第一次被线程获取的时候,虚拟机会将对象头中的标志位设置为01,同时将偏向模式设置为1,表示进入偏向锁模式。同时使用CAS操作把获取到这个锁对象的线程id记录在对象的Mark Word中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步代码块时,虚拟机都可以不再进行任何同步操作,即加锁、解锁、对Mark Word的更新操作等。

​ 但一旦出现另外一个线程去尝试获取这个锁的情况,偏向锁模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向,即将偏向模式设置为0。撤销后锁对象头中Mark Word的锁标志位恢复到未锁定状态01或轻量级锁定状态00。后续的线程竞争同步操作就会被升级为轻量级锁。

举个例子来说:

​ 当某个对象锁第一次被线程A获取时,首先会将锁对象对象头中的锁标志位设置为01,同时将偏向模式设置为1。然后使用CAS操作将锁对象头中Mark Word的偏向线程id设置为线程Aid,成功之后锁对象会进入偏向线程模式,同时线程A也就获取到了该对象的偏向锁。

​ 此后只要有线程来获取该对象锁时,都需要比较线程id与锁对象头中的偏向线程id是否一致。如果一致,则成功获取对象锁。如果不一致,则表示除了线程A之外还有其它线程来竞争锁对象。此时则需要根据锁对象目前是否处于被锁定状态决定是否撤销偏向锁,恢复到无锁状态或者轻量级锁状态。

​ 如果锁对象不处于被锁定状态,那么就将锁对象头中的偏向模式设置为0代表退出偏向模式,并将锁状态标识设置为01代表此锁对象恢复到无锁状态,同时移除偏向线程id。这种场景下锁对象会恢复到无锁状态,让该对象锁重新偏向其它的线程。

​ 如果锁对象处于被锁定状态,则代表有线程正在持有该对象锁。首先暂停线程A,撤销偏向锁,然后将锁对象升级为轻量级锁。

引入轻量级锁的原因

​ 轻量级锁是JDK 6中引入的一项优化策略,它的目的是为了在没有多线程竞争的且线程持有锁的时间不会过长的前提下,减少传统的重量级锁使用操作系统互斥量产生的额外性能开销。

​ 轻量级是相对于使用操作系统互斥量来实现的传统锁而言的,因此Java中传统的锁机制就被称为重量级锁。但是轻量级锁并不是用来替代重量级锁的。

​ 轻量级锁考虑的是竞争锁对象的线程不多,并且线程持有锁的时间不会过长的场景下。因为阻塞线程需要CPU从用户态切换到内核态,这种切换的代价是非常大的。如果刚将某一个线程阻塞了而锁对象又被释放掉了,那么CPU的频繁切换带来的额外性能开销就显得非常得不偿失,因此在这种场景下干脆就不阻塞竞争锁对象的线程,而选择让他自旋等待锁对象的释放。

轻量级锁的原理和升级

​ 当线程A即将进入同步块时,此时锁对象并没有锁定,锁状态标识位为01JVM虚拟机首先将在当前线程的栈帧中建立一个名为锁记录Lock Record的空间,用于存储锁对象目前的Mark Word的拷贝,官方为这份拷贝增加了一个前缀Displaced前缀,即为Displaced Mark Word

​ 然后JVM会使用CAS尝试将锁对象中的Mark Word更新为Lock Record的指针。如果CAS操作成功了,即代表该线程成功获取到了锁对象,并且对象Mark Word的锁标志位将设置为00,表示此对象处于轻量级锁定状态。

​ 如果CAS更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。此时JVM虚拟机首先会检测锁对象的Mark Word是否指向当前线程的栈帧。如果是,则说明当前线程获取锁对象成功,直接进入同步块继续执行即可。

​ 如果锁对象的Mark Word没有指向当前线程的栈帧,即线程在使用CAS操作尝试将锁对象的Mark Word更新为Lock Record指针操作时,发现此时Mark Word已经指向其它线程栈帧中的Lock Record,那么锁对象就被其它线程抢占了。此时该线程会一直使用CAS自旋来等待锁对象的释放。

​ 如果锁对象长时间没有被释放,那么线程将会一直自旋,长时间的自旋会消耗CPU的性能,因此自旋次数是有限制的。JVM中默认自旋次数为10,开发者可通过-XX:PreBlockSpin选项进行配置。如果自旋次数到了,锁对象还未被释放,或者线程在自旋过程中,又来了一个新的线程竞争该对象锁,那么轻量级锁将不再有效,必须要膨胀为重量级锁,所对象头中Mark Word锁标志位更新为10,此时Mark Word中存储的就是指向重量级锁的指针。碰撞到重量级锁之后,OS会将除了拥有锁对象的线程都阻塞,防止CPU空转。

禁止偏向锁延迟:

JVM中的偏向锁是默认开启的,并且启动时间一般会比应用启动慢几秒,如果不想有这个延迟,那么可以使用如下选项配置:

XX:BiasedLockingStartUpDelay=0

禁止偏向锁:

如果想不适用偏向锁,可以使用如下选项配置:

-XX:-UseBiasedLocking = false

以上三种锁的优缺点:

锁状态优点缺点适用场景
偏向锁加锁解锁不需要额外消耗,和非同步方法时间相差纳秒级别如果竞争线程不多,那么会带来额外的锁撤销的性能消耗基本没有线程竞争锁的同步场景
轻量级锁竞争的线程不会阻塞,使用自旋或者自适应自旋,提高程序的响应速度如果一直不能获取锁,长时间自旋会造成CPU消耗适用于少了线程竞争锁对象,且线程持有锁的时间不会过长,追求性能和响应速度的场景
重量级锁线程竞争不适合CPU自旋,不会导致CPU空转消耗CPU资源等待线程会被阻塞,响应时间长很多线程竞争锁,且锁持有时间较长,追求吞吐量的场景

8.synchronized的可重入性

​ 锁的可重入就是说,当线程A访问类X的同步方法时,获取到了锁对象,在该同步方法内又调用了该类的另外一个同步方法,此时线程A可以成功访问另外一个同步方法。简而言之就是当某一个线程获取到对象锁之后可以再次请求该对象锁,这是被允许的,这就是synchronized的可重入性。


9.synchronized的等待与唤醒

Object类中有如下三个方法:

public final native void notify();

public final native void notifyAll();

public final native void wait(long timeout) throws InterruptedException;

public final void wait(long timeout, int nanos) throws InterruptedException;
    
public final void wait() throws InterruptedException;

​ 在使用notify()notifyAll()wait()等方法时,必须处于synchronized同步代码块或者synchronized同步方法中,否则就会抛出IllegalMonitorStateException异常,这是因为在调用如上方法之前必须对锁对象进行同步,也就是拿到当前锁对象的monitor监视器。而monitor存在于对象头的Mark Word中,Mark Word中会存储指向monitor对象的引用指针。而synchronized关键字可以获取到与该对象关联的monitor对象,所以以上方法必须配合synchronized一起使用。


GitHub源码地址https://github.com/kapbc/Java-Kapcb/tree/master/src/main/java/com/kapcb/ccc/thread

备注:此文为笔者学习Java的笔记,鉴于本人技术有限,文中难免出现一些错误,感谢大家批评指正。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值