Java基础知识-线程篇

一、线程的基本概念

  • 线程与进程:线程是进程中的一个执行流程,一个进程可以运行多个线程。线程总是属于某个进程,进程中的多个线程共享进程的内存。
  • 并行与并发:并行指多个处理器或多核处理器同时处理多个任务;并发指多个任务在同一个CPU核上,按细分的时间片轮流交替执行,从逻辑上来看那些任务是同时执行的。

线程安全

在Java中,线程安全是指在多线程环境下,对共享资源(如对象、变量、数据结构等)进行访问和操作时,能够确保不会出现数据错误或不一致的情况。线程安全的实现主要涉及以下几个方面:

  1. 互斥访问共享资源:确保在同一时刻只有一个线程可以访问共享资源。这可以通过加锁机制来实现,比如使用 synchronized 关键字、ReentrantLockReadWriteLockStampedLock 等。

  2. 保证可见性:确保当一个线程修改了共享资源的值后,其他线程能够立即看到这个修改。在 Java 中,可以通过 volatile 关键字、synchronized 关键字、java.util.concurrent 包中的原子类(如 AtomicIntegerAtomicReference 等)来保证可见性。

  3. 保证原子性操作:确保某个操作是不可中断的整体操作,要么全部执行成功,要么全部不执行,不存在执行一部分的情况。原子性操作可以通过 synchronized 关键字、java.util.concurrent 包中的原子类来实现。

  4. 避免死锁和饥饿:设计并发程序时,需要避免出现死锁(多个线程相互等待对方持有的资源)和饥饿(某些线程长期无法获得所需的资源)的情况。

如何确保线程安全?

确保线程安全通常需要根据具体的场景和需求采取不同的策略和技术,以下是一些常见的方法:

  • 使用同步机制:通过 synchronized 关键字或 ReentrantLock 来保证对共享资源的互斥访问。例如:

synchronized (lockObject) {
    // 访问共享资源的代码
}

或者
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 访问共享资源的代码
} finally {
    lock.unlock();
}

  • 使用原子类:使用 java.util.concurrent.atomic 包中的原子类来进行原子操作,保证对变量的操作是线程安全的。例如:

        AtomicInteger count = new AtomicInteger(0);
        count.incrementAndGet(); // 原子性的增加操作

  • 使用线程安全的数据结构:Java 提供了多种线程安全的集合类,如 ConcurrentHashMapCopyOnWriteArrayList 等,它们已经在内部实现了线程安全机制,可以直接用于多线程环境。

  • 避免共享状态:尽量减少共享资源的使用,如果不必要,可以通过线程本地变量(ThreadLocal)来避免竞争和同步问题。

  • 合理设计并发结构:避免在锁的持有期间进行耗时操作,减小锁的粒度,使用读写锁来提高读操作的并发性等。

二、线程的创建

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。而创建一个一个新的线程主要是通过以下两种方式来实现:

方式1:继承Thread类的方式

代码实现:

//多线程
public static void main(String[] args) {
    Thread t =new Thread();
    t.start();//启动线程
}
  class  Mythread extends Thread{ 
    @Override
    public void run(){
        System.out.println("start new thread");
    }
}

方式2:实现Runnable接口

代码:

//多线程
public static void main(String[] args) {
    Thread t =new Thread(new Runthread());
    t.start();//启动线程
}

//实现Runable
 class Runthread implements Runnable{
    @Override
    public void run(){
        System.out.println("start new thread");
    }
}

其他:

java 8引入了lambda

public static void main(String[] args) {
    Thread t = new Thread(() -> {
        System.out.println("start new thread!");
    });
    t.start(); // 启动新线程
}

上述方法来自于廖雪峰的java教程:创建新线程 - Java教程 - 廖雪峰的官方网站 (liaoxuefeng.com)

方式3:通过Callable(同方法二的实现)

//1.创建一个类,实现Callable接口
class MyThread2 implements Callable<Integer> { //<>代表call方法的返回值
    @Override
    public Integer call() throws Exception {

        return null;
    }
}

拓展:

Callable接口:

package java.util.concurrent;

@FunctionalInterface
public interface Callable<V> {
    V call() throws Exception;
}

FutureTask类实现了RunnableFuture接口,RunnableFuture接口继承了Runnable接口。所以,FutureTask类实现了Runnable接口,那么FutureTask类就可以拿到Runnable的run方法。所以接下来创建线程类的时候,让它继承FutureTask类就可以拿到run方法。

为什么一般采用实现Runnable接口创建线程?

        因为使用实现Runnable接口的同时我们也能够继承其他类,并且可以拥有多个实现类,那么我们在拥有了Runable方法的同时也可以使用父类的方法;而在Java中,一个类只能继承一个父类,那么在继承了Thread类后我们就不能再继承其他类了。

三、Java线程状态以及其转变

线程共包括以下 5 种状态:

1. 新建状态(New): 线程对象被创建后,就进入了新建状态。例如,Thread thread = new Thread()。

2. 就绪状态(Runnable): 也被称为“可执行状态”。线程对象被创建后,其它线程调用了该对象的start()方法,从而来启动该线程。例如,thread.start()。处于就绪状态的线程,随时可能被CPU调度执行。

3. 运行状态(Running): 线程获取CPU权限进行执行。需要注意的是,线程只能从就绪状态进入到运行状态。

4. 阻塞状态(Blocked): 阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。阻塞的情况分三种:

  • (01) 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。
  • (02) 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。
  • (03) 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

5. 死亡状态(Dead): 线程执行完了或者因异常退出了run()方法,该线程结束生命周期。

wait(1000)和sleep(1000)的区别:

sleep(1000)

  • sleep() 是 Thread 类的静态方法,用于使当前线程暂停执行指定的时间(以毫秒为单位)。
  • 在调用 sleep() 方法期间,线程持有其锁,但是不会释放该锁,这意味着其他线程无法访问该线程所同步的对象。
  • 主要用于在不需要同步的情况下,暂停当前线程的执行,例如实现简单的时间延迟或者线程调度。

try {
    Thread.sleep(1000); // 暂停当前线程1秒钟
} catch (InterruptedException e) {
    // 处理中断异常
}

wait(1000)

  • wait() 方法是 Object 类的实例方法,用于在线程持有对象的锁时,暂停当前线程,并释放该对象的锁。
  • 线程必须在同步块或同步方法中调用 wait(),否则会抛出 IllegalMonitorStateException 异常。
  • wait(long timeout) 可以指定等待时间(以毫秒为单位),如果在指定时间内未被唤醒,则线程会自动苏醒并重新竞争对象的锁。

synchronized (sharedObject) {
    try {
        sharedObject.wait(1000); // 暂停当前线程1秒钟,并释放sharedObject的锁
    } catch (InterruptedException e) {
        // 处理中断异常
    }
}
使用场景:

  • sleep() 通常用于在不需要同步控制的情况下暂停线程执行,如延时操作。
  • wait() 通常用于线程间的协调和同步,等待特定条件的发生,例如等待某个条件满足后再继续执行。

四、线程中断机制

Java 提供了两种方式来处理线程中断:

  1. 设置中断状态(interrupt)

    • 每个线程都有一个 boolean 类型的中断状态标志,可以通过 Thread.interrupt() 方法将该标志设置为 true
    • 如果线程处于阻塞状态(如调用了 sleep()wait()join() 等方法),那么线程会收到一个 InterruptedException 异常,并且中断状态会被清除(重置为 false)。
    • 如果线程处于非阻塞状态,调用 interrupt() 方法仅仅是设置线程的中断状态为 true,线程可以通过 Thread.interrupted() 或 isInterrupted() 方法来检查中断状态并作出相应的响应。
  2. 处理中断状态

    • 线程在收到中断请求后,可以根据具体的业务逻辑决定如何处理中断。常见的处理方式包括停止线程的执行、清理资源、或者重新设置中断状态以便后续处理。

    • 通常情况下,线程可以在每次迭代或者某个可中断的点检查中断状态,例如:

                while (!Thread.interrupted()) {
    // 执行任务
    // 检查中断状态
    if (Thread.interrupted()) {
        // 处理中断
        break;
    }
}

Thread 类的中断方法和相关API

  • Thread.interrupt()

    • 将线程的中断状态设置为 true
    • 如果线程处于阻塞状态,会抛出 InterruptedException 异常,并清除中断状态。
    • 如果线程处于非阻塞状态,仅仅设置中断状态为 true
  • Thread.interrupted()

    • 静态方法,用于检查当前线程的中断状态,并清除中断状态(重置为 false)。
    • 如果线程已经中断,则返回 true,否则返回 false
  • Thread.isInterrupted()

    • 实例方法,用于检查调用该方法的线程的中断状态,但不清除中断状态。
    • 如果线程已经中断,则返回 true,否则返回 false

示例代码

以下是一个简单的示例,展示了如何使用线程的中断机制:

public class MyRunnable implements Runnable {
    @Override
    public void run() {
        try {
            while (!Thread.interrupted()) {
                // 执行任务
                System.out.println("Running...");
                Thread.sleep(1000); // 可能会抛出 InterruptedException
            }
        } catch (InterruptedException e) {
            // 捕获异常后处理中断
            System.out.println("Thread interrupted while sleeping");
            // 可以选择重新设置中断状态
            Thread.currentThread().interrupt(); // 重新设置中断状态
        }
        System.out.println("Thread exiting...");
    }

    public static void main(String[] args) {
        Thread thread = new Thread(new MyRunnable());
        thread.start();

        // 主线程等待一段时间后中断子线程
        try {
            Thread.sleep(5000);
            thread.interrupt(); // 请求中断子线程
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

五、线程池

简介

Java线程池是一种用于管理和重用线程的机制,它提供了一种在执行大量异步任务时有效控制线程的方式。使用线程池可以避免频繁创建和销毁线程所带来的性能开销,并能提高程序的响应速度。

创建线程池的方式

在Java中,创建线程池一般通过 java.util.concurrent.Executors 类的工厂方法来实现。主要的线程池类型有几种:

FixedThreadPool(固定大小线程池)

  • 创建一个固定大小的线程池,每次提交一个任务就创建一个线程,直到达到线程池的最大数量。

ExecutorService executor = Executors.newFixedThreadPool(int nThreads);
其中 nThreads 是线程池中线程的数量。

CachedThreadPool(缓存线程池)

  • 线程池的线程数量不固定,可以根据需要自动扩展,线程空闲一定时间后会被回收。

ExecutorService executor = Executors.newCachedThreadPool();

SingleThreadExecutor(单线程线程池)

  • 只有一个线程的线程池,保证所有任务按照指定顺序执行。、

ExecutorService executor = Executors.newSingleThreadExecutor();

ScheduledThreadPool(定时任务线程池)

  • 用于执行定时任务和具有固定周期的重复任务。

ScheduledExecutorService executor = Executors.newScheduledThreadPool(int corePoolSize);
其中 corePoolSize 是线程池中的基本线程数量。

线程池的工作原理

线程池的工作原理基本上可以归纳为以下几个步骤:

  1. 线程池初始化

    根据指定的参数(如线程池类型、核心线程数等),创建一个线程池对象。
  2. 任务提交

    当有任务需要执行时,可以通过 execute() 方法将任务提交给线程池。线程池会选择合适的线程来执行任务。
  3. 任务执行

    线程池根据任务的类型和当前线程池的状态,决定是复用现有线程还是创建新的线程来执行任务。
  4. 线程复用

    在线程池中,如果有空闲线程,任务会被分配给这些空闲线程执行,避免频繁地创建和销毁线程,提高性能。
  5. 任务队列管理

    如果当前线程池中的线程数量已达到设定的核心线程数,而且任务继续提交,新的任务会被放入任务队列中等待执行。
  6. 线程池扩展

    根据具体的线程池类型,如果任务数量过多,线程池可能会根据需要动态地增加线程数量(如缓存线程池),或者在特定时间间隔执行任务(如定时任务线程池)。
  7. 线程池关闭

    当不再需要线程池时,可以调用 shutdown() 方法来关闭线程池。关闭线程池后,它将拒绝新的任务提交,但会等待已经提交的任务执行完成(如果还有未执行的任务),然后释放线程池中的资源。

拒绝策略

在Java中,线程池(ThreadPoolExecutor)中的拒绝策略(Rejection Policy)定义了当线程池无法接受新任务时的处理方式。这种情况通常发生在以下几种情况下:

  1. 线程池已经达到最大容量,无法再创建新的线程来执行新提交的任务。
  2. 线程池已经被关闭,无法接受新任务。
  3. 任务队列已满,无法再将新任务添加到队列中等待执行。

拒绝策略的种类

Java中的ThreadPoolExecutor类提供了几种预定义的拒绝策略,分别是:

  1. AbortPolicy(默认策略)

    这是默认的拒绝策略,当线程池无法处理新任务时,会抛出一个 RejectedExecutionException 异常。
  2. CallerRunsPolicy

    如果线程池无法接受新任务,该策略会使用当前线程来执行被拒绝的任务。换句话说,由提交任务的线程(caller)自己执行该任务。
  3. DiscardPolicy

    当线程池无法接受新任务时,会直接丢弃掉这个任务,不做任何处理。
  4. DiscardOldestPolicy

    当线程池无法接受新任务时,会丢弃队列中等待时间最长的任务(即队列头部的任务),然后尝试重新提交新任务。

自定义拒绝策略

除了上述几种预定义的拒绝策略外,你也可以通过实现 RejectedExecutionHandler 接口来定义自己的拒绝策略。这个接口只有一个方法:

public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
通过实现 rejectedExecution 方法,你可以在拒绝策略触发时自定义处理逻辑,例如记录日志、将任务重新放回队列等

六、相关方法和关键字

run() 方法:

run() 方法定义在 Runnable 接口中,是线程的执行体,包含了线程需要执行的代码逻辑。

run() 方法用于定义线程需要执行的代码逻辑。当直接调用 run() 方法时,该方法会在当前线程的上下文中执行,而不会创建新的线程。

直接调用 run() 方法,不会实现多线程的并发执行,而只是在当前线程中按照顺序执行 run() 方法的内容。public class MyRunnable implements Runnable {
    @Override
    public void run() {
        // 线程的主体逻辑
        System.out.println("Running in thread: " + Thread.currentThread().getName());
    }
}
 
public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable = new MyRunnable();
        myRunnable.run(); // 在当前线程中执行 run() 方法
    }
}

start() 方法:


start() 方法是 Thread 类的一个方法,用于启动一个新的线程并使其开始执行。当调用 start() 方法时,会创建一个新的线程,并在新线程的上下文中执行 run() 方法的内容。

直接调用 start() 方法会导致新线程的创建和执行,实现多线程并发执行。

public class MyThread extends Thread {
    @Override
    public void run() {
        // 线程的主体逻辑
        System.out.println("Running in thread: " + Thread.currentThread().getName());
    }
}
 
public class Main {
    public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start(); // 启动一个新的线程,执行 run() 方法
    }
}

区别和使用场景

  • 调用方式

    • start() 方法由系统调用,用于启动新线程。
    • run() 方法需要程序员手动调用,通常作为 start() 方法内部的一个逻辑单元执行。
  • 执行方式

    • start() 方法会创建一个新的线程,并在新线程上运行 run() 方法的代码。
    • 直接调用 run() 方法时,会在当前线程的上下文中执行 run() 方法的代码,而不会创建新的线程。
  • 并发性

    • 通过 start() 方法启动的线程是并发执行的,即多个线程可以同时执行。
    • 直接调用 run() 方法相当于普通的方法调用,是顺序执行的,不具备多线程的并发特性。

volatile关键字

在Java中,volatile 是一个关键字,用于修饰变量。它的主要作用是确保变量的可见性和禁止指令重排序。具体来说,volatile 关键字具有以下几个主要特性和用法:

  1. 可见性(Visibility)

    • 当一个变量被 volatile 修饰时,线程在读取该变量的值时会直接从主内存中读取,而不是从线程的本地缓存中读取。这确保了当一个线程修改了变量的值后,其他线程能够立即看到最新的值。
  2. 禁止指令重排序

    • volatile 关键字禁止了虚拟机对指令进行重排序优化。普通的变量可以在编译器和处理器的优化下,改变执行顺序,但volatile 变量不会被重排序,从而确保了程序的执行顺序符合预期。
  3. 不保证原子性

    • 尽管 volatile 可以保证可见性,但它并不保证操作的原子性。如果多个线程同时对 volatile 变量进行读取和写入操作,仍然需要通过其他机制(如synchronized 或 Lock)来确保操作的原子性。
  4. 适用场景

    • 主要用于标记被多个线程访问的变量,且这些线程不需要互斥访问这些变量,但需要确保对变量的写入操作能被及时地看到。

示例用法:

public class SharedResource {
    private volatile int count = 0;

    public void increment() {
        count++; // 简单的加一操作
    }

    public int getCount() {
        return count; // 读取操作
    }
}

阻塞队列

阻塞队列(Blocking Queue)是一种特殊的队列,它支持在队列为空时获取元素的线程会被阻塞,以及在队列已满时添加元素的线程会被阻塞的操作。这种特性使得阻塞队列在多线程编程中非常有用,能够有效地控制线程之间的数据传输和任务调度。

工作原理

阻塞队列的工作原理主要基于两个核心操作:入队(put)和出队(take)。具体来说,阻塞队列的工作原理可以描述如下:

  1. 入队操作(put)

    当向队列中添加元素时,如果队列已满,入队操作会被阻塞,直到队列有空闲空间可以添加元素为止。
  2. 出队操作(take)

    当从队列中获取元素时,如果队列为空,出队操作会被阻塞,直到队列中有元素可供获取为止。

这种阻塞的机制使得阻塞队列可以用来实现生产者-消费者模式,其中生产者线程负责向队列中添加任务或数据,消费者线程则负责从队列中取出并处理这些任务或数据。

特性和应用场景

  • 线程安全:阻塞队列通常是线程安全的,它内部通过锁或者其他同步机制来保证多线程环境下的安全访问。

  • 等待和通知机制:阻塞队列的实现依赖于底层的等待和通知机制(如条件变量),以实现线程的阻塞和唤醒操作。

  • 性能优化:通过合理选择阻塞队列的实现和大小,可以优化系统的性能和资源利用率,例如通过调整队列的容量和超时等待时间来适应系统的负载情况。

Java 中的阻塞队列实现

在Java中,java.util.concurrent 包提供了多种类型的阻塞队列,如 ArrayBlockingQueueLinkedBlockingQueuePriorityBlockingQueue 等。每种队列都有不同的特性和适用场景,可以根据具体的需求选择合适的实现。

七、锁

在Java中,锁机制是多线程编程中非常重要的概念,用于保证数据的一致性和线程安全。以下是对Java中常见锁机制的理解:

1. 乐观锁(Optimistic Locking)

乐观锁是一种乐观地认为对数据的访问不会造成冲突的策略。在Java中,乐观锁通常通过版本号(versioning)或时间戳来实现。基本思想是:在更新数据时,不加锁,而是先读取数据,然后在更新时检查在此期间数据有没有被其他线程修改过。

  • 实现方式:通常使用版本号或者时间戳。在读取数据时,同时记录当前的版本号或时间戳。在写入数据时,先比较当前版本号或时间戳和之前记录的是否一致,若一致则更新,否则认为数据已经被修改,需要进行冲突解决。

2. 悲观锁(Pessimistic Locking)

悲观锁是一种悲观地认为数据会被其他线程修改的策略,因此在访问数据前先获取锁。Java中最常见的实现是使用 synchronized 关键字或者 ReentrantLock。

  • 实现方式:通过获取锁来确保在同一时间只有一个线程能够访问共享资源。其他线程需要等待当前线程释放锁才能继续执行。

3. 同步共享锁(Synchronized Lock)

同步共享锁是Java中最基本的锁机制,使用 synchronized 关键字来实现。它是一种独占锁,保证了同一时刻只有一个线程可以执行被 synchronized 保护的代码块或方法。

  • 实现方式:使用 synchronized 关键字修饰代码块或方法。

4. 读写锁(ReadWriteLock)

读写锁允许多个线程同时读共享资源,但是在写操作时必须互斥(排他性)。它可以提高读操作的并发性,适用于读多写少的场景。

  • 实现方式:Java提供了 ReentrantReadWriteLock 类来实现读写锁,它包含了一个读锁和一个写锁,可以根据情况进行获取和释放。

5. 分段锁(Segment Locking)

分段锁是一种面向并发控制的技术,通常应用于对大规模数据结构的并发访问优化。它将数据分成多个段(Segment),每个段都可以被独立地加锁,从而减小锁的粒度,提高并发度。

  • 实现方式:例如 ConcurrentHashMap 就使用了分段锁的思想来提高并发性能。

6. 死锁(Deadlock)

死锁是多线程编程中一种常见的问题,指两个或多个线程互相持有对方所需要的资源,从而导致所有线程都无法继续执行的状态。

  • 典型案例:两个线程分别持有资源 A 和资源 B,并且互相等待对方释放资源,导致双方都无法继续执行下去。

6.1死锁形成的四大条件

  1. 互斥条件(Mutual Exclusion):至少有一个资源必须处于非共享模式,即一次只能由一个进程使用。如果其他进程请求该资源,则请求进程必须等到该资源被释放为止。

  2. 请求和保持条件(Hold and Wait):一个进程因请求资源而阻塞时,不会释放已经获得的资源。换言之,即使某个进程已经持有了一部分资源,但如果它又请求其他进程或线程持有的资源,而又不能立即获得,那么它会继续保持已有的资源。

  3. 不剥夺条件(No Preemption):资源不能被强制性抢占,只能在进程使用完之后自愿释放。这意味着只能由持有资源的进程自己来释放,其他进程无法将其抢占。

  4. 循环等待条件(Circular Wait):存在一种进程等待序列,其中每个进程都在等待下一个进程所持有的资源。例如,进程A等待进程B的资源,进程B等待进程C的资源,进程C又等待进程A的资源,形成一个闭环。

加锁方式

synchronized 关键字

synchronized 关键字是Java中最基本和最常用的加锁方式,它可以用来修饰代码块或方法,实现对共享资源的互斥访问。

修饰代码块

synchronized (lockObject) {
    // 访问共享资源的代码
}

这里 lockObject 是一个对象,用于控制对共享资源的访问。

修饰方法

public synchronized void someMethod() {
    // 访问共享资源的代码
}

ReentrantLock

ReentrantLockjava.util.concurrent 包中提供的一种灵活的加锁方式,它比 synchronized 提供了更多的功能,如可中断锁、超时获取锁、公平锁等。

  • 基本使用

ReentrantLock lock = new ReentrantLock();
lock.lock(); // 获取锁
try {
    // 访问共享资源的代码
} finally {
    lock.unlock(); // 释放锁
}

读写锁(ReadWriteLock)

ReadWriteLock 接口提供了读写分离锁,允许多个线程同时读取共享资源,但只有一个线程能写入共享资源。

  • 使用方式

ReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock(); // 获取读锁
try {
    // 读取共享资源的代码
} finally {
    rwLock.readLock().unlock(); // 释放读锁
}

rwLock.writeLock().lock(); // 获取写锁
try {
    // 写入共享资源的代码
} finally {
    rwLock.writeLock().unlock(); // 释放写锁
}

StampedLock

StampedLock 是 JDK8 新增的锁机制,比 ReentrantReadWriteLock 更加灵活,提供了乐观锁的支持,可以减少锁竞争。

  • 使用方式

StampedLock lock = new StampedLock();
long stamp = lock.readLock(); // 获取读锁
try {
    // 读取共享资源的代码
} finally {
    lock.unlockRead(stamp); // 释放读锁
}

stamp = lock.writeLock(); // 获取写锁
try {
    // 写入共享资源的代码
} finally {
    lock.unlockWrite(stamp); // 释放写锁
}
 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值