【Java基础】Java多线程总结

本文深入探讨了Java中的进程与线程,解释了并发与并行的区别,并展示了多线程的基本使用,包括继承Thread、实现Runnable和Callable接口。详细讨论了线程的创建、启动、同步锁、线程通信以及线程调度策略。此外,还介绍了线程池的工作原理和使用,以及在实际应用中的问题和解决方案,如死锁和生产者消费者模式。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

进程和线程

  • 进程是:一个应用程序(1个进程是一个软件)。
  • 线程是:一个进程中的执行场景/执行单元。
    注意:一个进程可以启动多个线程。两个进程是独立的,不共享资源。进程A和进程B的 内存独立不共享。
    线程A和线程B,堆内存 和 方法区 内存共享。但是 栈内存 独立,一个线程一个栈。
    java中之所以有多线程机制,目的就是为了 提高程序的处理效率

并发和并行

  • 并发:单核CPU运行多线程时,时间片很快地切换,线程轮流执行CPU
  • 并行:多核CPU在同一时刻执行多线程
    在这里插入图片描述使用多线程可以发挥多核CPU强大的能力

多线程的基本使用

1. 定义任务

继承Thread类 (可以说是 将任务和线程合并在一起)

任务写在Thread类的run方法里,有单继承的局限性
创建多线程时,每个任务有成员变量时不共享,必须加static才能做到共享
Runnable和Callable解决了Thread的局限性

  • run()不会启动线程,只是单纯地调用方法,不会分配新的分支栈,(这种方式就是单线程。)
  • t.start() 方法的作用是:启动一个分支线程,在JVM中开辟一个新的栈空间,这段代码任务完成之后,瞬间就结束了。
    这段代码的任务只是为了开启一个新的栈空间,只要新的栈空间开出来,start()方法就结束了。线程就启动成功了。
    启动成功的线程会自动调用run方法,并且run方法在分支栈的栈底部(压栈)。
    run方法在分支栈的栈底部,main方法在主栈的栈底部。run和main是平级的

实现Runnable接口 (可以说是 将任务和线程分开了)

  • Runbale相比Callable有以下的局限性:
  • 任务没有返回值
  • 任务无法抛异常给调用方
    这2种方式都有一个缺陷,就是在执行完任务之后无法获取执行结果。
    第二种方式实现接口比较常用,因为一个类实现了接口,它还可以去继承其它的类,更灵活。

实现Callable接口详解

这种方式实现的线程可以获取线程的返回值。
之前讲解的那两种方式是无法获取线程返回值的,因为run方法返回void。

  • 优点:获取到线程的执行结果
  • 缺点:效率比较低,在获取t线程执行结果的时候,当前线程受阻塞,效率较低(很慢!)。
class Main {
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        //1. 创建一个“未来任务类”对象
        FutureTask task = new FutureTask(new Callable() {
            @Override
            public Object call() throws Exception {
                // 线程执行一个任务,执行之后可能有一个执行结果
                // 模拟执行
                System.out.println("call method begin");
                Thread.sleep(1000*10);
                System.out.println("call method end");
                int a = 100;
                int b = 200;
                return a + b; // 自动装箱(300结果变成Integer)
            }
        });

        // 2. 创建线程对象
        Thread t = new Thread(task);

        // 3. 启动线程
        t.start();

        // 4. 在主线程中获取t线程的返回结果
        // get()方法的执行会导致“当前线程阻塞”
        Object obj = task.get();
        System.out.println("线程执行结果:" + obj);

        // main方法这里的程序要想执行必须等待get()方法的结束
        // 而get()方法可能需要很久。因为get()方法是为了拿另一个线程的执行结果
        // 另一个线程执行是需要时间的。
        System.out.println("hello word!");
    }
}

2. 创建线程方法

  • 通过Thread类直接创建线程
  • 利用线程池内部创建线程

3. 启动线程的方法

  • 调用线程的start()方法
// 启动继承Thread类的任务
new T().start();

// 启动继承Thread匿名内部类的任务 可用lambda优化
Thread t = new Thread(){
  @Override
  public void run() {
    log.info("我是Thread匿名内部类的任务");
  }
};

//  启动实现Runnable接口的任务
new Thread(new R()).start();

//  启动实现Runnable匿名实现类的任务
new Thread(new Runnable() {
    @Override
    public void run() {
        log.info("我是Runnable匿名内部类的任务");
    }
}).start();

//  启动实现Runnable的lambda简化后的任务
new Thread(() -> log.info("我是Runnable的lambda简化后的任务")).start();

// 启动实现了Callable接口的任务 结合FutureTask 可以获取线程执行的结果
FutureTask<String> target = new FutureTask<>(new C());
new Thread(target).start();
log.info(target.get());

以上各个线程相关的类的类图如下:
在这里插入图片描述

4. sleep方法

静态方法:Thread.sleep(1000);
作用: 让当前线程进入休眠,进入“阻塞状态”,放弃占有CPU时间片,让给其它线程使用。
这行代码出现在A线程中,A线程就会进入休眠。
这行代码出现在B线程中,B线程就会进入休眠。
Thread.sleep()方法,可以做到这种效果:
间隔特定的时间,去执行一段特定的代码,每隔多久执行一次。
wait 和 sleep的区别?
二者都会让线程进入阻塞状态,有以下区别

  1. wait是Object的方法 sleep是Thread的方法
    2. wait会立即释放锁 sleep不会释放锁
  2. wait后线程的状态是Watting sleep后线程的状态为 Time_Waiting
    上下文切换
    多核cpu下,多线程是并行工作的,如果线程数多,单个核又会并发的调度线程,运行时会有上下文切换的概念
    cpu执行线程的任务时,会为线程分配时间片,以下几种情况会发生# # 上下文切换。
  • 线程的cpu时间片用完
  • 垃圾回收
  • 线程自己调用了 sleep、yield(线程的礼让)、wait、join、park、synchronized、lock 等方法
    当发生上下文切换时,操作系统会保存当前线程的状态,并恢复另一个线程的状态。jvm中有块内存地址叫程序计数器,用于记录线程执行到哪一行代码,是线程私有的

1. 线程的礼让-yield()

yield()方法会让运行中的线程切换到就绪状态,重新争抢cpu的时间片,争抢时是否获取到时间片看cpu的分配。

  • 线程的优先级
    线程内部用1~10的数来调整线程的优先级,默认的线程优先级为NORM_PRIORITY:5
    cpu比较忙时,优先级高的线程获取更多的时间片
    cpu比较闲时,优先级设置基本没用

2. 守护线程

默认情况下,java进程需要等待所有线程都运行结束,才会结束,有一种特殊线程叫守护线程,当所有的非守护线程都结束后,即使它没有执行完,也会强制结束。
默认的线程都是非守护线程。
垃圾回收线程就是典型的守护线程

3. 线程的阻塞

从操作系统层面和java层面阻塞的定义可能不同,但是广义上使得线程阻塞的方式有下面几种

  • BIO阻塞,即使用了阻塞式的io流
  • sleep(long time) 让线程休眠进入阻塞状态,当休眠时间结束后,重新争抢cpu的时间片继续运行
  • a.join() 调用该方法的线程进入阻塞,等待a线程执行完恢复运行
  • sychronized或ReentrantLock 造成线程未获得锁进入阻塞状态
  • 获得锁之后调用wait()方法 也会让线程进入阻塞状态
  • LockSupport.park() 让线程进入阻塞状态

isInterrupted() 获取线程的打断标记 ,调用后不会修改线程的打断标记
**interrupt()**方法用于中断线程
可以打断sleep,wait,join等显式的抛出InterruptedException方法的线程,但是打断后,线程的打断标记还是false
打断正常线程 ,线程不会真正被中断,但是线程的打断标记为true
**加粗样式# 线程的状态
线程的状态可从 操作系统层面分为五种状态 从java api层面分为六种状态

1. 五种状态

  • 初始状态:创建线程对象时的状态
  • 可运行状态(就绪状态):调用start()方法后进入就绪状态,也就是准备好被cpu调度执行
  • 运行状态:线程获取到cpu的时间片,执行run()方法的逻辑
  • 阻塞状态: 线程被阻塞,放弃cpu的时间片,等待解除阻塞重新回到就绪状态争抢时间片
  • 终止状态: 线程执行完成或抛出异常后的状态
    在这里插入图片描述

2. 六种状态

  • NEW 线程对象被创建
  • Runnable 线程调用了start()方法后进入该状态,该状态包含了三种情况
    • 就绪状态 :等待cpu分配时间片
    • 运行状态:进入Runnable方法执行任务
    • 阻塞状态:BIO 执行阻塞式io流时的状态
  • Blocked 没获取到锁时的阻塞状态(同步锁章节会细说)
  • WAITING 调用wait()、join()等方法后的状态
  • TIMED_WAITING 调用 sleep(time)、wait(time)、join(time)等方法后的状态
  • TERMINATED 线程执行完成或抛出异常后的状态
    在这里插入图片描述

线程调度

1. 常见的线程调度模型

  • 抢占式调度模型:
    那个线程的优先级比较高,抢到的CPU时间片的概率就高一些/多一些。
    java采用的就是抢占式调度模型。
  • 均分式调度模型:
    平均分配CPU时间片。每个线程占有的CPU时间片时间长度一样。
    平均分配,一切平等。
    有一些编程语言,线程调度模型采用的是这种方式。

2. java中提供了哪些方法是和线程调度有关系

在这里插入图片描述

3. 线程方法

  • yield()方法:让位,当前线程暂停,回到就绪状态,让给其它线程。
  • join()方法:将一个线程合并到当前线程中,当前线程受阻塞,加入的线程执行直到结束

同步锁

1. 线程安全

问题有可能出现在多个线程访问共享资源,当多个线程读写共享资源时,如果发生指令交错,就会出现问题
指令交错指的是 java代码在解析成字节码文件时,java代码的一行代码在字节码中可能有多行,在线程上下文切换时就有可能交错。
线程安全指的是多线程调用同一个对象的临界区的方法时,对象的属性值一定不会发生错误,这就是保证了线程安全。
线程安全的类一定所有的操作都线程安全吗?
开发中经常会说到一些线程安全的类,如ConcurrentHashMap,线程安全指的是类里每一个独立的方法是线程安全的,但是方法的组合就不一定是线程安全的。
- 成员变量和静态变量是否线程安全?

  • 如果没有多线程共享,则线程安全
  • 如果存在多线程共享
    - 多线程只有读操作,则线程安全
    • 多线程存在写操作,写操作的代码又是临界区,则线程不安全
  • 局部变量是否线程安全?
    - 局部变量是线程安全的
    • 局部变量引用的对象未必是线程安全的
      • 如果该对象没有逃离该方法的作用范围,则线程安全
      • 如果该对象逃离了该方法的作用范围,比如:方法的返回值,需要考虑线程安全

2. synchronized

同步锁也叫对象锁,是锁在对象上的,不同的对象就是不同的锁。
该关键字是用于保证线程安全的,是阻塞式的解决方案。
注意: 不要理解为一个线程加了锁 ,进入 s ynchronized代码块中就会一直执行下去。如果时间片切换了,也会执行其他线程,再切换回来会紧接着执行,只是不会执行到有竞争锁的资源,因为当前线程还未释放锁。

在代码块使用synchronized

当一个线程执行完synchronized的代码块后 会唤醒正在等待的线程
synchronized实际上使用对象锁保证临界区的原子性 临界区的代码是不可分割的 不会因为线程切换所打断
重点:加锁是加在对象上,一定要保证是同一对象,加锁才能生效
synchronized后面小括号() 中传的这个“数据”是相当关键的。这个数据必须是 多线程共享 的数据。才能达到多线程排队。
执行原理
1、假设t1和t2线程并发,开始执行以下代码的时候,肯定有一个先一个后。
2、假设t1先执行了,遇到了synchronized,这个时候自动找“后面共享对象”的对象锁,找到之后,并占有这把锁,然后执行同步代码块中的程序,在程序执行过程中一直都是占有这把锁的。直到同步代码块代码结束,这把锁才会释放。
3、假设t1已经占有这把锁,此时t2也遇到synchronized关键字,也会去占有后面共享对象的这把锁,结果这把锁被t1占有,t2只能在同步代码块外面等待t1的结束,直到t1把同步代码块执行结束了,t1会归还这把锁,此时t2终于等到这把锁,然后t2占有这把锁之后,进入同步代码块执行程序。
4、这样就达到了线程排队执行。
重中之重:
这个共享对象一定要选好了。这个共享对象一定是你需要排队
执行的这些线程对象所共享的。

class Account {
    private String actno;
    private double balance; //实例变量。

    //对象
    Object o= new Object(); // 实例变量。(Account对象是多线程共享的,Account对象中的实例变量obj也是共享的。)

    public Account() {
    }

    public Account(String actno, double balance) {
        this.actno = actno;
        this.balance = balance;
    }

    public String getActno() {
        return actno;
    }

    public void setActno(String actno) {
        this.actno = actno;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    //取款的方法
    public void withdraw(double money){
        /**
         * 以下可以共享,金额不会出错
         * 以下这几行代码必须是线程排队的,不能并发。
         * 一个线程把这里的代码全部执行结束之后,另一个线程才能进来。
         */
        synchronized(this) {
        //synchronized(actno) {
        //synchronized(o) {
        
        /**
         * 以下不共享,金额会出错
         */
                  /*Object obj = new Object();
                synchronized(obj) { // 这样编写就不安全了。因为obj2不是共享对象。
                synchronized(null) {//编译不通过
                String s = null;
                synchronized(s) {//java.lang.NullPointerException*/
            double before = this.getBalance();
            double after = before - money;
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            this.setBalance(after);
        //}
    }
}

class AccountThread extends Thread {
    // 两个线程必须共享同一个账户对象。
    private Account act;

    // 通过构造方法传递过来账户对象
    public AccountThread(Account act) {
        this.act = act;
    }

    public void run(){
        double money = 5000;
        act.withdraw(money);
        System.out.println(Thread.currentThread().getName() + "对"+act.getActno()+"取款"+money+"成功,余额" + act.getBalance());
    }
}

public class Test {
    public static void main(String[] args) {
        // 创建账户对象(只创建1个)
        Account act = new Account("act-001", 10000);
        // 创建两个线程,共享同一个对象
        Thread t1 = new AccountThread(act);
        Thread t2 = new AccountThread(act);

        t1.setName("t1");
        t2.setName("t2");
        t1.start();
        t2.start();
    }
}

在实例方法上使用synchronized

表示共享对象一定是 this 并且同步代码块是整个方法体。

在静态方法上使用synchronized

表示找 类锁。类锁永远只有1把。
就算创建了100个对象,那类锁也只有1把。
注意区分:

  • 对象锁:1个对象1把锁,100个对象100把锁。
  • 类锁:100个对象,也可能只是1把类锁。

3. 线程通信

wait+notify

wait()将线程进入阻塞状态,notify()将线程唤醒
Thread0,1先竞争到锁执行了代码后,2,3,4,5线程同时来执行临界区的代码,开始竞争锁
Thread-0先获取到对象的锁,关联到monitor的owner,同步代码块内调用了锁对象的wait()方法,调用后会进入waitSet等待,Thread-1同样如此,此时Thread-0的状态为Waitting
Thread2、3、4、5同时竞争,2获取到锁后,关联了monitor的owner,3、4、5只能进入EntryList中等待,此时2线程状态为 Runnable,3、4、5状态为Blocked
2执行后,唤醒entryList中的线程,3、4、5进行竞争锁,获取到的线程即会关联monitor的owner
3、4、5线程在执行过程中,调用了锁对象的notify()或notifyAll()时,会唤醒waitSet的线程,唤醒的线程进入entryList等待重新竞争锁
在这里插入图片描述

park&unpark

多线程并发相关问题

1. 什么情况下会存在问题

  • 多线程并发
  • 有共享数据
  • 共享数据有修改行为

2. 怎么解决线程安全问题

synchronized会让程序的执行效率降低,用户体验不好。系统的用户吞吐量降低。用户体验差。在不得已的情况下再选择线程同步机制。

  • 尽量使用局部变量代替实例变量和静态变量
  • 如果必须是实例变量,可以考虑创建多个对象,这样实例对象的内存就不共享了
  • 如果不能使用局部变量,对象也不能创建多个,这时候只能选择synchronized了。线程同步机制。

死锁(Deadlock)

在这里插入图片描述
比如:t1想先穿衣服在穿裤子
t2想先穿裤子在传衣服
此时:t1拿到衣服,t2拿到裤子;
由于t1拿了衣服,t2找不到衣服;t2拿了裤子,t1找不到裤子
就会导致死锁的发生!

public class Thread_DeadLock {
    public static void main(String[] args) {
        Dress dress = new Dress();
        Trousers trousers = new Trousers();
        //t1、t2共享dress和trousers。
        Thread t1 = new Thread(new MyRunnable1(dress, trousers), "t1");
        Thread t2 = new Thread(new MyRunnable2(dress, trousers), "t2");
        t1.start();
        t2.start();
    }
}

class MyRunnable1 implements Runnable{
    Dress dress;
    Trousers trousers;

    public MyRunnable1() {
    }

    public MyRunnable1(Dress dress, Trousers trousers) {
        this.dress = dress;
        this.trousers = trousers;
    }

    @Override
    public void run() {
        synchronized(dress){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (trousers){
                System.out.println("--------------");
            }
        }
    }
}

class MyRunnable2 implements Runnable{
    Dress dress;
    Trousers trousers;

    public MyRunnable2() {
    }

    public MyRunnable2(Dress dress, Trousers trousers) {
        this.dress = dress;
        this.trousers = trousers;
    }

    @Override
    public void run() {
        synchronized(trousers){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (dress){
                System.out.println("。。。。。。。。。。。。。。");
            }
        }
    }
}

class Dress{

}

class Trousers{

}

生产者和消费者模式

  • 生产线程负责生产,消费线程负责消费。
  • 生产线程和消费线程要达到均衡。
  • 这是一种特殊的业务需求,在这种特殊的情况下需要使用wait方法和notify方法。

线程池

1. 优势

  • 降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗
  • 提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行
  • 提高线程的可管理性。线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

2. 使用方法

线程池的真正实现类是 ThreadPoolExecutor,其构造方法有如下4种:

  • corePoolSize(必需):核心线程数。默认情况下,核心线程会一直存活,但是当将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
  • maximumPoolSize(必需):线程池所能容纳的最大线程数。当活跃线程数达到该数值后,后续的新任务将会阻塞。
  • keepAliveTime(必需):线程闲置超时时长。如果超过该时长,非核心线程就会被回收。如果将 allowCoreThreadTimeout 设置为 true 时,核心线程也会超时回收。
  • unit(必需):指定 keepAliveTime 参数的时间单位。常用的有:TimeUnit.MILLISECONDS(毫秒)、TimeUnit.SECONDS(秒)、TimeUnit.MINUTES(分)。
  • workQueue(必需):任务队列。通过线程池的 execute() 方法提交的 Runnable 对象将存储在该参数中。其采用阻塞队列实现。
  • threadFactory(可选):线程工厂。用于指定为线程池创建新线程的方式。
  • handler(可选):拒绝策略。当达到最大线程数时需要执行的饱和策略。
    使用流程 :
// 创建线程池
ThreadPoolExecutor threadPool = new ThreadPoolExecutor(CORE_POOL_SIZE,
                                             MAXIMUM_POOL_SIZE,
                                             KEEP_ALIVE,
                                             TimeUnit.SECONDS,
                                             sPoolWorkQueue,
                                             sThreadFactory);
// 向线程池提交任务
threadPool.execute(new Runnable() {
    @Override
    public void run() {
        ... // 线程执行的任务
    }
});
// 关闭线程池
threadPool.shutdown(); // 设置线程池的状态为SHUTDOWN,然后中断所有没有正在执行任务的线程
threadPool.shutdownNow(); // 设置线程池的状态为 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表

3. 工作原理

在这里插入图片描述

4. 参数

任务队列(workQueue)

任务队列是基于阻塞队列实现的,即采用生产者消费者模式,在 Java 中需要实

  • BlockingQueue 接口:但 Java 已经为我们提供了 7 种阻塞队列的实现
  • ArrayBlockingQueue:一个由数组结构组成的有界阻塞队列(数组结构可配合指针实现一个环形队列)。
  • LinkedBlockingQueue: 一个由链表结构组成的有界阻塞队列,在未指明容量时,容量默认为 Integer.MAX_VALUE。
  • PriorityBlockingQueue: 一个支持优先级排序的无界阻塞队列,对元素没有要求,可以实现 Comparable 接口也可以提供 Comparator 来对队列中的元素进行比较。跟时间没有任何关系,仅仅是按照优先级取任务。
  • DelayQueue:类似于PriorityBlockingQueue,是二叉堆实现的无界优先级阻塞队列。要求元素都实现 Delayed 接口,通过执行时延从队列中提取任务,时间没到任务取不出来。
  • SynchronousQueue: 一个不存储元素的阻塞队列,消费者线程调用 take() 方法的时候就会发生阻塞,直到有一个生产者线程生产了一个元素,消费者线程就可以拿到这个元素并返回;生产者线程调用 put() 方法的时候也会发生阻塞,直到有一个消费者线程消费了一个元素,生产者才会返回。
  • LinkedBlockingDeque: 使用双向队列实现的有界双端阻塞队列。双端意味着可以像普通队列一样 FIFO(先进先出),也可以像栈一样 FILO(先进后出)。
  • LinkedTransferQueue: 它是ConcurrentLinkedQueue、LinkedBlockingQueue 和 SynchronousQueue 的结合体,但是把它用在 ThreadPoolExecutor 中,和 LinkedBlockingQueue 行为一致,但是是无界的阻塞队列。

线程工厂(threadFactory)

线程工厂指定创建线程的方式,需要实现 ThreadFactory 接口,并实现 newThread(Runnable r) 方法。该参数可以不用指定,Executors 框架已经为我们实现了一个默认的线程工厂
拒绝策略(handler)
当线程池的线程数达到最大线程数时,需要执行拒绝策略。拒绝策略需要实现 RejectedExecutionHandler 接口,并实现 rejectedExecution(Runnable r, ThreadPoolExecutor executor) 方法。不过 Executors 框架已经为我们实现了 4 种拒绝策略:

  • AbortPolicy(默认):丢弃任务并抛出 RejectedExecutionException 异常。
  • CallerRunsPolicy:由调用线程处理该任务。
  • DiscardPolicy:丢弃任务,但是不抛出异常。可以配合这种模式进行自定义的处理方式。
  • DiscardOldestPolicy:丢弃队列最早的未处理任务,然后重新尝试执行任务。

5. 功能线程池

Executors已经为我们封装好了 4 种常见的功能线程池,如下:

  • 定长线程池(FixedThreadPool)
  • 定时线程池(ScheduledThreadPool )
  • 可缓存线程池(CachedThreadPool)
  • 单线程化线程池(SingleThreadExecutor)
    在这里插入图片描述
    Executors 的 4 个功能线程池虽然方便,但现在已经不建议使用了,而是建议直接通过使用 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
    其实 Executors 的 4 个功能线程有如下弊端:
  • FixedThreadPool 和 SingleThreadExecutor:主要问题是堆积的请求处理队列均采用 LinkedBlockingQueue,可能会耗费非常大的内存,甚至 OOM。
  • CachedThreadPool 和 ScheduledThreadPool:主要问题是线程数最大数是 Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至 OOM。

参考文献

Java多线程(超详细!)_一个快乐的野指针~的博客-优快云博客_多线程java
Java 多线程:彻底搞懂线程池_孙强 Jimmy的博客-优快云博客_java 多线程池
Java线程池实现原理及其在美团业务中的实践

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值