Java多线程4:synchronized的使用场景和原理简介

前言:synchronized是Java中的关键字,是一种同步锁,用来完成多线程条件下同步工作的。只有拿到锁的线程才能执行synchronized修饰的方法或代码块,且其他线程获得锁的唯一方法是等目前拿到锁的线程执行完方法将锁释放,如果synchronized修饰的代码块或方法没执行完是不会释放这把锁的,这就保证了拿到锁的线程可以一次性把它调用的方法或代码块执行完。


一、synchronized使用

1.1 、synchronized介绍

在多线程并发编程中synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。

在Java1.5中,synchronize是性能低效的。因为这是一个重量级操作,需要调用操作接口,导致有可能加锁消耗的系统时间比加锁以外的操作还多。相比之下使用Java提供的Lock对象,性能更高一些。

但是到了Java1.6,发生了变化。synchronized在语义上很清晰,可以进行很多优化,有适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在Java1.6上synchronized的性能并不比Lock差。官方也表示,他们也更支持synchronized,在未来的版本中还有优化余地。

synchronized可以修饰普通方法,静态方法和代码块。当synchronized修饰一个方法或者一个代码块的时候,它能够保证在同一时刻最多只有一个线程执行该段代码。

  • 对于普通同步方法,锁是当前实例对象(不同实例对象之间的锁互不影响)。

  • 对于静态同步方法,锁是当前类的Class对象。

  • 对于同步方法块,锁是Synchonized括号里配置的对象。

当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。

1.2 、使用场景

synchronized最常用的使用场景就是多线程并发编程时线程的同步。这边还是举一个最常用的列子:多线程情况下银行账户存钱和取钱的列子。

public class SynchronizedDemo {


    public static void main(String[] args) {
        BankAccount myAccount = new BankAccount("accountOfMG",10000.00);
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.deposit(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);
                }
            }).start();
        }
        for(int i=0;i<100;i++){
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        int var = new Random().nextInt(100);
                        Thread.sleep(var);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    double deposit = myAccount.withdraw(1000.00);
                    System.out.println(Thread.currentThread().getName()+" balance:"+deposit);

                }
            }).start();
        }
    }

    private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }

        public double deposit(double amount){
            balance = balance + amount;
            return balance;
        }

        public double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }
}

上面的列子中,首先初始化了一个银行账户,账户的余额是10000.00,然后开始了200个线程,其中100个每次向账户中存1000.00,另外100个每次从账户中取1000.00。如果正常执行的话,账户中应该还是10000.00。但是我们执行多次这段代码,会发现执行结果基本上都不是10000.00,而且每次结果 都是不一样的。

出现上面这种结果的原因就是:在多线程情况下,银行账户accountOfMG是一个共享变量,对共享变量进行修改如果不做线程同步的话是会存在线程安全问题的。比如说现在有两个线程同时要对账户accountOfMG存款1000,一个线程先拿到账户的当前余额,并且将余额加上1000。但是还没将余额的值刷新回账户,另一个线程也来做相同的操作。此时账户余额还是没加1000之前的值,所以当两个线程执行完毕之后,账户加的总金额还是只有1000。

synchronized就是Java提供的一种线程同步机制。使用synchronized我们可以非常方便地解决上面的银行账户多线程存钱取钱问题,只需要使用synchronized修饰存钱和取钱方法即可:

private static class BankAccount{
        String accountName;
        double balance;

        public BankAccount(String accountName,double balance){
            this.accountName = accountName;
            this.balance = balance;
        }
        //这边给出一个编程建议:当我们对共享变量进行同步时,同步代码块最好在共享变量中加
        public synchronized double deposit(double amount){
            balance = balance + amount;
            return balance;
        }
        
        public synchronized double  withdraw(double amount){
            balance = balance - amount;
            return balance;
        }

    }

1.3 、synchronized实现的原理

synchronized的功能是基于monitorenter和monitorexit指令实现的。monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

monitorenter和monitorexit指令的底层是lock和unlock指令。

1.4、 synchronized的内存语意

进入synchronized块的内存语义是把在synchronized块内使用到的变量从线程的工作内存中清除,这样在synchronized块内使用到该变量时就不会从线程的工作内存中获取,而是直接从主内存中获取。退出synchronized块的内存语义是把在synchronized块内对共享变量的修改刷新到主内存。

除了可以解决共享变量内存可见性问题外,synchronized经常被用来实现原子性操作(需要注意的是synchronized保证的原子操作是指代码块的执行不会被另外一个线程打断,只有当一个线程执行完之后,其他线程才有获得执行的机会)。另外请注意,synchronized关键字会引起线程上下文切换并带来线程调度开销。

1.5、synchronized锁升级

JDK1.6 之后做了一些优化,为了减少获得锁和释放锁带来 的性能开销,引入了偏向锁、轻量级锁的概念。因此大家会发现在 synchronized 中,锁存在四种状态 分别是:无锁、偏向锁、轻量级锁、重量级锁; 锁的状态根据竞争激烈的程度从低到高不断升级。

整个锁升级的过程非常复杂,我尽力去除一些无用的环节,简单来描述整个升级的机制:


 二、synchronized的修饰方法和修饰代码块区别

synchronized有几种用法

2.1、用于方法

class B {
    //在方法前面加上synchronized关键字表示作用于该方法
    //需要注意方法有两种,一种静态方法,一种非静态方法
    //两者区别在于,当修饰静态时候,大家都调用的是同一个。当修饰非静态方法时候,调用的是每个对象自己的那个方法,因为非静态域或方法是每个对象各自都有一份的,静态方法是所有对象公有的。
    synchronized public static void mB(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
        }
    }
    synchronized public void mC(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
        }
    }    
}

2.2、用于代码块

class A {
    public static void test() {
        //修饰代码块的情况也有两种,这里表示对类进行同步
        synchronized (A.class) {
            System.out.println("haha");
        }
    }
    public void test2() {
        //这里表示对当前对象进行同步,两者区别看下面锁有几种
        synchronized (this) {
            System.out.println("haha");
        }
    }
}

2.3、锁有几种

类锁

类锁,是用来锁类的,我们知道一个类的所有对象共享一个class对象,共享一组静态方法,类锁的作用就是使持有者可以同步地调用静态方法。当synchronized指定修饰静态方法或者class对象的时候,拿到的就是类锁,类锁是所有对象共同争抢一把。

//B中有两个方法mB和mC
//mB是synchronized修饰静态方法,拿到类锁
//mC是synchronized修饰非静态方法,拿到的也是类锁
class B {
    synchronized public static void mB(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
        }
    }

    public void mC(String value) {
        synchronized (B.class) {
            for (int i = 0; i < 1000; i++) {
                System.out.print(value);
            }
        }
    }
}

对象锁

对象锁,是用来锁对象的,虚拟机为每个的非静态方法和非静态域都分配了自己的空间,不像静态方法和静态域,是所有对象共用一组。

所以synchronized修饰非静态方法或者this的时候拿到的就是对象锁,对象锁是每个对象各有一把的。

//类C中有两个方法mB和mC
//mB是synchronized非静态方法,拿到对象锁
//mC是synchronized修饰this,拿到的也是对象锁
class C {
    synchronized publi void mB(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
        }
    }

    public void mC(String value) {
        synchronized (this) {
            for (int i = 0; i < 1000; i++) {
                System.out.print(value);
            }
        }
    }
}

2.4、对象锁和类锁的使用

对象锁

下例中,两个线程调用同一个对象b的mB方法。最终结果是输出了1000次“1”之后输出了1000次“2”。可见两个线程对此方法的调用实现了同步。

class B {
    //修饰非静态方法拿到对象锁
    synchronized public void mB(String name) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(name);
        }
    }
    //修饰this拿到对象锁
    public void mB2(String name) throws InterruptedException {
        synchronized(this) {
            for (int i = 0; i < 1000; i++) {
                System.out.print(name);
            }
        }
    }
}

public class test {
    
    public static void main(String[] args) {

        B b = new B();
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //线程1的调用处
                    b.mB("1");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    //线程2的调用处
                    b.mB2("2");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

类锁

下面代码中对静态方法mA和mC的调用实现了同步,结果没有交替输出1和2,而是一次性输出完成后再输出的另一种

class B {
    //修饰静态方法,调用取得类锁
    synchronized public static void mB(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
        }
    }
    //修饰class对象,调用取得静类锁
    public static void mC(String value) {
        synchronized (B.class) {
            for (int i = 0; i < 1000; i++) {
                System.out.print(value);
            }
        }
    }

    public static void main(String[] args) {

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    B.mB("1");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                B.mC("2");
            }
        });
    }
}

类锁和对象锁同时存在

同时存在的情况下,两者做不到同步。类锁和对象锁是两种锁。下述情况,类B的静态方法和代码块功能都是打印100个value值,但是静态方法是类锁,而代码块锁this,是对象锁。所以代码块和静态方法交替执行、交替打印,大家可复制代码自行验证。

class B {
    //静态方法,上类锁,函数功能为连续打印1000个value值,调用时会传1,所以会打印1000个1
    synchronized public static void mB(String value) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            System.out.print(value);
            Thread.sleep(100);
        }
    }

    public void mC(String value) throws InterruptedException {
        //修饰this上对象锁,函数功能也是连续打印1000个value值,调用时会传2,所以会打印1000个2
        synchronized (this) {
            for (int i = 0; i < 1000; i++) {
                System.out.print(value);
                Thread.sleep(100);
            }
        }
    }

    public static void main(String[] args) {

        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    B.mB("1");

                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        });
        B b = new B();
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    b.mC("2");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread.start();
        thread2.start();

    }
}

2.5 、线程同步问题

同步是对同一把锁而言的,同步时在多线程竞争同一把锁的时候才能实现,如果多个线程竞争不同的锁,那么线程是不能同步的:

  • 两个线程,一个取对象锁,一个取类锁,则不能同步

  • 两个线程,一个取a对象锁,一个取b对象锁,不能同步

线程同步:

即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态。


三、Java对象头

3.1、Java对象头组成

上面提到,当线程进入synchronized方法或者代码块时需要先获取锁,退出时需要释放锁。那么这个锁信息到底存在哪里呢?其实这个锁是存在对象的对象头中的。

Java对象保存在内存中时,由以下三部分组成:

  • 对象头

  • 实例数据

  • 对齐填充字节

而对象头又由下面几部分组成:

  • Mark Word

  • 指向类的指针

  • 数组长度(只有数组对象才有)

3.2、Mark Word

Mark Word记录了对象和锁有关的信息,当这个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。

Mark Word在不同的锁状态下存储的内容不同,在32位JVM中是这么存的:

其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态。Epoch是指偏向锁的时间戳。

JDK1.6以后的版本在处理同步锁时存在锁升级的概念,JVM对于同步锁的处理是从偏向锁开始的,随着竞争越来越激烈,处理方式从偏向锁升级到轻量级锁,最终升级到重量级锁。

JVM一般是这样使用锁和Mark Word的:

step1:当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

step2:当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

step3:当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

step4:当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

step5:偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

step6:轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

step7:自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

指向类的指针

该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。Java对象的类数据保存在方法区。

 数组长度

只有数组对象保存了这部分数据。该数据在32位和64位JVM中长度都是32bit。


四、synchronized对锁的优化

Java 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”的概念。在Java 6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。

在聊偏向锁、轻量级锁和重量级锁之前我们先来聊下锁的宏观分类。锁从宏观上来分类,可以分为悲观锁与乐观锁。注意,这里说的的锁可以是数据库中的锁,也可以是Java等开发语言中的锁技术。悲观锁和乐观锁其实只是一类概念(对某类具体锁的总称),不是某种语言或是某个技术独有的锁技术。

乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。数据库中的共享锁也是一种乐观锁。

悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中典型的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如ReentrantLock。数据库中的排他锁也是一种悲观锁。

4.1、偏向锁

Java 6之前的synchronized会导致争用不到锁的线程进入阻塞状态,线程在阻塞状态和runnbale状态之间切换是很耗费系统资源的,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁。为了缓解上述性能问题,Java 6开始,引入了轻量锁与偏向锁,默认启用了自旋,他们都属于乐观锁

偏向锁更准确的说是锁的一种状态。在这种锁状态下,系统中只有一个线程来争夺这个锁。线程只要简单地通过Mark Word中存放的线程ID和自己的ID是否一致就能拿到锁。下面简单介绍下偏向锁获取和升级的过程。

还是就着这张图讲吧,会清楚点。

当系统中还没有访问过synchronized代码时,此时锁的状态肯定是“无锁状态”,也就是说“是否是偏向锁”的值是0,“锁标志位”的值是01。此时有一个线程1来访问同步代码,发现锁对象的状态是"无锁状态",那么操作起来非常简单了,只需要将“是否偏向锁”标志位改成1,再将线程1的线程ID写入Mark Word即可。

如果后续系统中一直只有线程1来拿锁,那么只要简单的判断下线程1的ID和Mark Word中的线程ID,线程1就能非常轻松地拿到锁。但是现实往往不是那么简单的,现在假设线程2也要来竞争同步锁,我们看下情况是怎么样的。

step1:线程2首先根据“是否是偏向锁”和“锁标志位”的值判断出当前锁的状态是“偏向锁”状态,但是Mark Word中的线程ID又不是指向自己(此时线程ID还是指向线程1),所以此时回去判断线程1还是否存在;

step2:假如此时线程1已经不存在了,线程2会将Mark Word中的线程ID指向自己的线程ID,锁不升级,仍为偏向锁;

step3:假如此时线程1还存在(线程1还没执行完同步代码,【不知道这样理解对不对,姑且先这么理解吧】),首先暂停线程1,设置锁标志位为00,锁升级为“轻量级锁”,继续执行线程1的代码;线程2通过自旋操作来继续获得锁。

在JDK6中,偏向锁是默认启用的。它提高了单线程访问同步资源的性能。但试想一下,如果你的同步资源或代码一直都是多线程访问的,那么消除偏向锁这一步骤对你来说就是多余的。事实上,消除偏向锁的开销还是蛮大的。

所以在你非常熟悉自己的代码前提下,大可禁用偏向锁:

 -XX:-UseBiasedLocking=false

4.2、轻量级锁

"轻量级锁"锁也是一种锁的状态,这种锁状态的特点是:当一个线程来竞争锁失败时,不会立即进入阻塞状态,而是会进行一段时间的锁自旋操作,如果自旋操作拿锁成功就执行同步代码,如果经过一段时间的自旋操作还是没拿到锁,线程就进入阻塞状态。

①. 轻量级锁加锁流程
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

②. 轻量级锁解锁流程
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

4.3、重量级锁

因为自旋会消耗CPU,为了避免无用的自旋(比如获得锁的线程被阻塞住了),一旦锁升级成重量级锁,就不会再恢复到轻量级锁状态。当锁处于这个状态下,其他线程试图获取锁时,都会被阻塞住,当持有锁的线程释放锁之后会唤醒这些线程,被唤醒的线程就会进行新一轮的夺锁之争。

4.4、锁自旋(轻量级锁在获取锁时会进行自旋)

自旋锁原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗CPU的,说白了就是让CPU在做无用功,线程不能一直占用CPU自旋做无用功,所以需要设定一个自旋等待的最大时间。如果持有锁的线程执行的时间超过自旋等待的最大时间扔没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会停止自旋进入阻塞状态。

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起操作的消耗!但是如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用cpu做无用功,线程自旋的消耗大于线程阻塞挂起操作的消耗,其它需要cup的线程又不能获取到cpu,造成cpu的浪费。

JDK7之后,锁的自旋特性都是由JVM自身控制的,不需要我们手动配置。

4.5、偏向锁/轻量级锁/重量级锁对比

关于偏向锁的使用场景,并不是说系统中一直只有一个线程在执行同步代码,而是说同一时刻只有一个线程来争抢同步代码的锁;

关于轻量级锁的使用场景,可以有多个线程来争抢锁,但是每个线程占用锁的时间非常短,这样其他争抢的线程只要等待一下(自旋)就能获取锁,而不需要进入block状态引起上下文切换。

4.6、锁的优化

锁消除:指的是JVM检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。

使用读写锁:读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

锁粗化:假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;或者有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。


参考链接,隆重推荐:

Java并发编程系列博客传送门

Java中Synchronized的用法(简单介绍)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Java架构何哥

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

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

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

打赏作者

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

抵扣说明:

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

余额充值