Java CyclicBarrier 循环屏障的源码深度解析与应用

本文深入讲解CyclicBarrier的原理和应用,对比CountDownLatch,演示如何使用CyclicBarrier控制多线程同步,使其在所有线程到达指定点后一并执行。

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

详细介绍了CyclicBarrier循环屏障的原理和应用,以及与CountDownLatch的对比!

1 CyclicBarrier的概述

public class CyclicBarrier
extends Object

CyclicBarrier来自于JDK1.5的JUC包,也被称作循环屏障/同步屏障/循环屏障,被作为一种多线程并发控制工具来使用。它可以使一定数量的线程反复地在“屏障”位置处“汇集”,当线程到达“屏障”位置时将调用await方法,这个方法将阻塞该线程直到所有线程都到达屏障位置。如果足够数量的线程都到达屏障位置,那么屏障将打开,此时所有的线程都将被唤醒进而释放执行,而屏障将被重置以便下次使用。

通过它可以实现让一组线程互相等待共同到达某个状态之后再全部同时执行,叫做“循环”是因为当足够数量的等待线程都被释放以后,CyclicBarrier可以被重复使用。

2 CyclicBarrier的原理

2.1 基本结构

在这里插入图片描述

通过uml类图可知,CyclicBarrier是使用了ReentrantLock和Condition来完成屏障效果,本质上底层还是基于AQS 的,只不过更加高级。

有两个和关键属性parties和count,parties是在创建CyclicBarrier的时候指定的值,后续不可更改,表示屏障点数,或者说表示需要多少线程到达屏障(调用await)后,所有线程才会打破屏障继续往下运行。count则初始化为parties的值,每当有一个线程到达屏障调用await方法之后,count就就递减1;当count 为0 时,表示所需要的所有线程都到了屏障,此时屏障可以被打破。

变量parties始终用来记录所需总线程个数,而当count 值变为0后,又会将parties 的值赋给count,从而进行复用。使用两个变量的原因就是为了实现CyclicBarrier 的可复用性。

barrierCommand是一个任务,当所需要的线程都到达屏障后执行的回调任务。一个Generation内部类表示屏障的实现,generation属性表示当前屏障。

这里的lock锁用于控制线程并发的,保证代线程安全,await、reset、isBroken、getNumberWaiting方法都需要获取锁。

CyclicBarrier的构造器,就是初始化比如lock锁、trip条件变量、parties等一些属性。

/**
 * 用于保护屏障入口的锁
 * 操作、访问count、generation等全局变量的时候都需要获取锁
 */
private final ReentrantLock lock = new ReentrantLock();
/**
 * 达到屏障并且不能放行的线程在trip条件变量上等待
 */
private final Condition trip = lock.newCondition();
/**
 * 打破一次屏障所需要的线程数量
 * 表示需要多少线程到达屏障(调用await)后,所有线程才会打破屏障继续往下运行。
 */
private final int parties;

/**
 * count表示打破一次屏障还需要的线程数量,初始化值等于parties
 * 每当有一个线程到达屏障调用await方法之后,count就就递减1
 * 当count 为0 时,表示所需要的所有线程都到了屏障,此时屏障可以被打破
 * <p>
 * 变量parties始终用来记录所需总线程个数,而当count 值变为0后,又会将parties 的值赋给count,从而进行复用。
 * 使用两个变量的原因就是为了实现CyclicBarrier 的可复用性。
 */
private int count;


/**
 *当所需要的线程都到达屏障后执行的回调任务
 */
private final Runnable barrierCommand;
/**
 * generation属性的对象代表当前屏障
 */
private Generation generation = new Generation();

/**
 * Generation内部类代表屏障
 */
private static class Generation {
    /**
     * 用来记录当前屏障是否被打破
     */
    boolean broken = false;
}

/**
 * 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,
 * 并在打破屏障时执行给定的任务,该任务由最后一个到达屏障的线程执行。
 *
 * @param parties       打破屏障之前必须要调用await()方法的线程数
 * @param barrierAction 打破屏障之时指定的任务;如果该参数为null,则不执行任务
 * @throws IllegalArgumentException 如果 parties 小于 1
 */
public CyclicBarrier(int parties, Runnable barrierAction) {
    //parties校验
    if (parties <= 0) throw new IllegalArgumentException();
    //初始化属性
    this.parties = parties;
    this.count = parties;
    this.barrierCommand = barrierAction;
}

/**
 * 创建一个新的 CyclicBarrier,它将在给定数量的参与者(线程)处于等待状态时启动,
 * 但它不会在打破屏障时执行给定的任务。
 *
 * @param parties 打破屏障之前必须要调用await()方法的线程数
 * @throws IllegalArgumentException 如果 parties 小于 1
 */
public CyclicBarrier(int parties) {
    //调用每一个构造器,barrierCommand传递null
    this(parties, null);
}

2.2 await等待

public int await()

这个await可不是Condition的方法。调用CyclicBarrier 的await方法表示当前线程到达屏障点。如果当前线程不是将到达的最后一个线程,当前线程将一直等待。

满足下面条件之一将会被唤醒,可能还会抛出异常:

  1. 所需的所有线程都调用了await()方法,也就是所有线程都到了屏障点,则当前线程被唤醒,继续向下执行;
  2. 其他某个线程中断当前线程,则当前线程被唤醒并且清除当前线程的已中断状态,随后将打破当前屏障,并唤醒其他线程,最后抛出 InterruptedException;
  3. 其他某个线程中断其他等待的线程,则当前线程被唤醒并抛出 BrokenBarrierException;
  4. 其他线程调用了reset()方法打破了当前屏障并重置了屏障,则当前线程被唤醒并抛出 BrokenBarrierException;
  5. 其他线程在等待当前屏障时超时,则当前线程被唤醒并抛出 BrokenBarrierException;
  6. 最后一个线程在执行回调任务过程中发生异常,则当前线程被唤醒并抛出 BrokenBarrierException。

如果当前线程是最后一个将要到达的线程,则当前线程不会等待,并且如果构造方法中提供了一个非空的回调任务,那么在允许其他线程继续运行之前(唤醒其他等待的线程之前),当前线程将运行该任务。如果在执行回调任务过程中发生异常,则该异常将传播到当前线程中,将 barrier 置于损坏状态,最后当前线程抛出该异常。

内部调用了dowait核心方法。

/**
 1. 非超时等待
 2.  3. @return 到达的当前线程的索引,其中,索引为 getParties() - 1 表示第一个到达的线程,0表示最后一个到达的线程
 */
public int await() throws InterruptedException, BrokenBarrierException {
    try {
        //调用dowait核心方法
        return dowait(false, 0L);
    } catch (TimeoutException toe) {
        //在await()方法中不会发生TimeoutException异常
        throw new Error(toe); // cannot happen
    }
}

2.2.1 dowait核心方法

dowait是CyclicBarrier的核心方法,完成各种判断逻辑,比如等待、唤醒机制。

大概步骤如下,看的时候建议结合注释看:

  1. 首先就是获取lock锁,保证线程安全。
  2. 在一个try块中。获取当前屏障,使用局部变量g保存;
  3. 如果当前屏障被打破了,那么直接抛出BrokenBarrierException异常。
  4. 如果当前线程被中断了,那么调用breakBarrier打破当前屏障,随后抛出InterruptedException异常。
  5. count自减1,index记录到达的当前线程的索引,即自减1之后的count值;
  6. 如果index为0,那么表示当前线程是最后一个达到屏障的线程,所需要的所有线程都到达了屏障点:
    1. ranAction变量表示回调任务执行是否成功,初始化为false,表示执行失败;
    2. 开启一个try块:
      1. command变量记录回调任务;
      2. 如果command不为null,那么在当前线程(最后一个达到屏障的线程)中执行回调任务;
      3. 到这一步,表示command的执行没有抛出异常,那么ranAction设置为true;
      4. 调用nextGeneration重置屏障,并唤醒其他线程;
      5. 返回0,方法正常结束。
    3. 无论上面的有没有抛出异常(特指command任务的执行),都会执行finally代码块:
      1. 如果ranAction为false,表示command执行抛出了异常。那么调用breakBarrier打破当前屏障,并唤醒其他线程,随后抛出遇到的异常。
  7. 到这一步,表示index不为0,那么表示当前线程不是最后一个达到屏障的线程,可能需要等待。开启一个死循环:
    1. 开启一个开启一个try块:
      1. 如果是非超时等待,那么调用trip.await(),当前线程在trip条件变量上等待,直到被中断或者被唤醒。
      2. 否则,就是超时等待。如果超时时间大于0。那么调用trip.awaitNanos(),当前线程在trip条件变量上超时等待最多nanos纳秒,直到被中断或者被唤醒或者超时等待完毕,返回nanos,表示剩余超时等待时间。
    2. 在catch块中尝试捕获InterruptedException,即线程中断异常,如果捕获成功:
      1. 如果屏障g还是当前屏障,并且g没有被打破
        1. 那么当前线程调用breakBarrier打破当前屏障,并唤醒其他线程,随后抛出该异常。
      2. 否则,表示一种极端情况,即当前线程因为被中断而唤醒,但是由于cpu轮换,或者锁已被其他线程获取,还没有来得及打破屏障。此时最后一个线程就调用nextGeneration重置屏障成功,或者屏障被其他线程打破,或者屏障被其他线程reset。
        1. 设置当前线程的中断状态。这种情况如果是屏障正常打破,那么不需要抛出异常,算作等待完成,而如果是其他情况将会在下面判断并抛出异常!
    3. 到这一步,表示被唤醒或者超时时间到了,或者被中断但是其他线程更改了屏障设置的情况。如果屏障g被打破,那么抛出BrokenBarrierException异常。
    4. 如果屏障g不是当前屏障,说明最后一个线程已经到了,并且该屏障被打破并重置,返回index,正常结束。
    5. 如果是超时操作,并且等于超时时间小于等于0,那么说明是超时时间到了,并且该屏障还没有被打破。那么当前线程调用breakBarrier打破当前屏障,并唤醒其他线程,最后抛出TimeoutException异常。
    6. 到这里,说明屏障g既没有被打破也没有被替换,那么继续下一次循环,此时可能会继续等待。这种情况发生的概率很低,这种唤醒被称作“虚假唤醒”
  8. 最终需要在finally中释放lock锁。
/**
 1. CyclicBarrier的核心方法,完成各种判断逻辑,比如等待、唤醒机制
 2.  3. @param timed 是否是超时等待
 4. @param nanos 超时时间纳秒
 5. @return 到达的当前线程的索引,其中,索引为 getParties() - 1 表示第一个到达的线程,0表示最后一个到达的线程
 */
private int dowait(boolean timed, long nanos)
        throws InterruptedException, BrokenBarrierException,
        TimeoutException {
    final ReentrantLock lock = this.lock;
    //首先就是获取lock锁
    lock.lock();
    try {
        //获取当前屏障,使用局部变量g保存
        final Generation g = generation;
        /*
         * 如果当前屏障被打破了
         * 那么直接抛出BrokenBarrierException异常
         */
        if (g.broken)
            throw new BrokenBarrierException();
        /*
         * 如果当前线程被中断了
         * 那么调用breakBarrier打破当前屏障,随后抛出InterruptedException异常
         */
        if (Thread.interrupted()) {
            breakBarrier();
            throw new InterruptedException();
        }
        //count自减1,index记录到达的当前线程的索引,即自减1之后的count值
        int index = --count;
        //如果index为0,那么表示当前线程是最后一个达到屏障的线程,所需要的所有线程都到达了屏障点
        if (index == 0) {  // tripped
            //ranAction变量表示回调任务执行是否成功,初始化为false,表示执行失败
            boolean ranAction = false;
            try {
                //command变量记录回调任务
                final Runnable command = barrierCommand;
                //如果command不为null,那么在当前线程(最后一个达到屏障的线程)中执行回调任务
                if (command != null)
                    command.run();
                //到这一步,表示command的执行没有抛出异常,那么ranAction设置为true。
                ranAction = true;
                //调用nextGeneration重置屏障,并唤醒其他线程
                nextGeneration();
                //返回0
                return 0;
            } finally {
                //finally中,如果ranAction为false,表示command执行抛出了异常
                if (!ranAction)
                    //那么调用breakBarrier打破当前屏障,并唤醒其他线程,随后抛出遇到的异常
                    breakBarrier();
            }
        }
        /*
         * 到这一步,表示index不为0,那么表示当前线程不是最后一个达到屏障的线程,可能需要等待
         * 开启一个死循环
         */
        for (; ; ) {
            try {
                /*如果是非超时等待*/
                if (!timed)
                    //那么调用trip.await(),当前线程在trip条件变量上等待,直到被中断或者被唤醒
                    trip.await();
                    /*否则,就是超时等待,如果超时时间大于0*/
                else if (nanos > 0L)
                    //那么调用trip.awaitNanos(),当前线程在trip条件变量上超时等待最多nanos纳秒,直到被中断或者被唤醒或者超时等待完毕
                    //返回nanos,表示剩余超时等待时间
                    nanos = trip.awaitNanos(nanos);
            } catch (InterruptedException ie) {
                //在await或者awaitNanos等待时如果抛出了InterruptedException异常,即被中断了
                /*如果屏障g还是当前屏障,并且g没有被打破*/
                if (g == generation && !g.broken) {
                    //那么当前线程调用breakBarrier打破当前屏障,并唤醒其他线程
                    breakBarrier();
                    //随后抛出该异常
                    throw ie;
                }
                /*
                 * 否则,表示一种极端情况,即当前线程因为被中断而唤醒,但是由于cpu轮换,或者锁已被其他线程获取,还没有来得及打破屏障
                 * 此时最后一个线程就调用nextGeneration重置屏障成功,或者屏障被其他线程打破,或者屏障被其他线程reset
                 */
                else {
                    // We're about to finish waiting even if we had not
                    // been interrupted, so this interrupt is deemed to
                    // "belong" to subsequent execution.
                    //设置当前线程的中断状态。这种情况如果是屏障正常打破,那么不需要抛出异常,算作等待完成,而如果是其他情况将会在下面判断并抛出异常!
                    Thread.currentThread().interrupt();
                }
            }
            //到这一步,表示被唤醒或者超时时间到了,或者被中断但是其他线程更改了屏障的情况
            //如果屏障g被打破,那么抛出BrokenBarrierException异常
            if (g.broken)
                throw new BrokenBarrierException();
            //如果屏障g不是当前屏障,说明最后一个线程已经到了,并且该屏障被打破并重置,返回index,正常结束
            if (g != generation)
                return index;
            //如果是超时操作,并且等于超时时间小于等于0,那么说明是超时时间到了,并且该屏障还没有被打破
            if (timed && nanos <= 0L) {
                //那么当前线程调用breakBarrier打破当前屏障,并唤醒其他线程
                breakBarrier();
                //最后抛出TimeoutException异常
                throw new TimeoutException();
            }
            //到这里,说明屏障g既没有被打破也没有被替换,那么继续下一次循环,此时可能会继续等待
            //这种情况发生的概率很低,这种唤醒被称作“虚假唤醒”
        }
    } finally {
        //最终解锁
        lock.unlock();
    }
}
2.2.1.1 breakBarrier打破屏障

在出现异常的时候调用的方法,且必须在获得锁之后才会调用。用于打破当前屏障,表明这个屏障已经失效了。主要做三件事:

  1. 打破当前屏障(broken设置为true);
  2. count重置为parties;
  3. 唤醒所有在trip条件变量上等待的线程。
/**
 1. 打破当前屏障(broken设置为true)
 2. 1 打破当前屏障(broken设置为true)
 3. 2 count重置为parties
 4. 3 唤醒所有在trip条件变量上等待的线程
 */
private void breakBarrier() {
    generation.broken = true;
    count = parties;
    trip.signalAll();
}
2.2.1.2 nextGeneration重置屏障

在正常完成的时候调用的方法,且必须在获得锁之后才会调用。用于重置当前屏障,表明这个屏障已经使用完毕。主要做三件事:

  1. 唤醒所有在trip条件变量上等待的线程;
  2. count重置为parties;
  3. 重新初始化一个Generation对象,赋给generation,这就是下一个屏障。

可以看到,以前的屏障被第丢弃,但是并没有被打破(broken没有设置为true)。

/**
 * 重置屏障,设置下一个屏障
 * 1 唤醒所有在trip条件变量上等待的线程
 * 2 count重置为parties
 * 3 重新初始化一个Generation对象,赋给generation
 */
private void nextGeneration() {
    // signal completion of last generation
    trip.signalAll();
    // set up next generation
    count = parties;
    generation = new Generation();
}

2.3 await(timeout, unit)超时等待

public int await(long timeout,TimeUnit unit)

调用await方法表示当前线程到达屏障点。如果当前线程不是将到达的最后一个线程,当前线程将最多等待指定的超时时间。满足下面条件之一将会被唤醒,可能还会抛出异常:

  1. 所需的所有线程都调用了await()方法,也就是所有线程都到了屏障点,则当前线程被唤醒,继续向下执行;
  2. 其他某个线程中断当前线程,则当前线程被唤醒并且清除当前线程的已中断状态,随后将打破当前屏障,最后抛出 InterruptedException;
  3. 其他某个线程中断其他等待的线程,则当前线程被唤醒并抛出 BrokenBarrierException;
  4. 其他线程调用了reset()方法打破了当前屏障并重置了屏障,则当前线程被唤醒并抛出 BrokenBarrierException;
  5. 其他线程在等待当前屏障时超时,则当前线程被唤醒并抛出 BrokenBarrierException,
  6. 最后一个线程在执行回调任务过程中发生异常,则当前线程被唤醒并抛出 BrokenBarrierException,
  7. 当前线程等待超时,那么当前线程被唤醒,随后打破当前屏障并唤醒其他线程,最后抛出 TimeoutException。如果超时时间小于等于0,那么该方法根本不会等待,有可能直接抛出TimeoutException异常!

如果当前线程是最后一个将要到达的线程,则当前线程不会等待,并且如果构造方法中提供了一个非空的回调任务,那么在允许其他线程继续运行之前(唤醒其他等待的线程之前),当前线程将运行该任务。如果在执行回调任务过程中发生异常,则该异常将传播到当前线程中,将 barrier 置于损坏状态,最后当前线程抛出该异常。

/**
 * 超时等待
 *
 * @param timeout 超时时间
 * @param unit    时间单位
 * @return 到达的当前线程的索引,其中,索引为 getParties() - 1 表示第一个到达的线程,0表示最后一个到达的线程
 */
public int await(long timeout, TimeUnit unit)
        throws InterruptedException,
        BrokenBarrierException,
        TimeoutException {
    //调用dowait方法
    return dowait(true, unit.toNanos(timeout));
}

2.4 reset重置屏障

public void reset()

将打破当前屏障并且重置新屏障。所有在屏障处等待的线程将会被唤醒并且抛出BrokenBarrierException。

实际上就是在获得锁之后连续调用breakBarrier和nextGeneration方法!

/**
 * 将打破当前屏障并且重置新屏障。所有在屏障处等待的线程将会被唤醒并且抛出BrokenBarrierException。
 */
public void reset() {
    final ReentrantLock lock = this.lock;
    //获取lock锁
    lock.lock();
    try {
        //打破当前屏障
        breakBarrier();   // break the current generation
        //重置新屏障
        nextGeneration(); // start a new generation
    } finally {
        //解锁
        lock.unlock();
    }
}

3 CyclicBarrier的使用

对三个变量a、b、c进行随机初始化,它们的初始化会消耗不等的时间。当三个变量初始化完毕之后,a+b+c的值赋给变量d。随后需要分别对这四个变量执行3种不同的运算:a - b + c + d;a + b - c – d;a - b - c + d。要求执行两次上面的操作。

使用CyclicBarrier配合线程池可以非常轻松且快速的实现这个业务!

/**
 1. @author lx
 */
public class CyclicBarrierTest {
    /**
     * 四个初始化参数
     */
    static volatile int a, b, c, d = -1;
    /**
     * 执行轮次
     */
    final static int NUM = 2;

    /**
     * 初始化CyclicBarrier,要求3个参与者,传递回调任务HookMethod
     */
    static CyclicBarrier cyclicBarrier = new CyclicBarrier(3, new HookMethod());

    public static void main(String[] args) {
        //使用线程池执行任务
        ExecutorService executorService = Executors.newCachedThreadPool();
        executorService.execute(new Run1());
        executorService.execute(new Run2());
        executorService.execute(new Run3());
        executorService.shutdown();
    }


    /**
     * 任务3:初始化a
     * 等到变量都初始化完毕之后执行:a - b - c
     */
    static class Run1 implements Runnable {
        @Override
        public void run() {
            for (int i = 1; i <= NUM; i++) {
                //假设每个初始化任务耗费不等的时间
                int j = ThreadLocalRandom.current().nextInt(5);
                LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(j));
                a = j;
                System.out.println("a第" + i + "次初始化 = " + j);
                //等待
                try {
                    System.out.println(Thread.currentThread().getName() + ":await");
                    cyclicBarrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
                System.out.println("任务:a - b - c + d = " + (a - b - c + d));
            }
        }
    }

    /**
     * 任务3:初始化b
     * 等到变量都初始化完毕之后执行:a + b - c
     */
    static class Run2 implements Runnable {
        @Override
        public void run() {
            for (int i = 1; i <= NUM; i++) {
                //假设每个初始化任务耗费不等的时间
                int j = ThreadLocalRandom.current().nextInt(5);
                LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(j));
                b = j;
                System.out.println("b第" + i + "次初始化 = " + j);
                //等待
                try {
                    System.out.println(Thread.currentThread().getName() + ":await");
                    cyclicBarrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
                System.out.println("任务:a + b - c - d = " + (a + b - c - d));
            }
        }
    }

    /**
     * 任务3:初始化c
     * 等到变量都初始化完毕之后执行:a - b + c
     */
    static class Run3 implements Runnable {
        @Override
        public void run() {
            for (int i = 1; i <= NUM; i++) {
                //假设每个初始化任务耗费不等的时间
                int j = ThreadLocalRandom.current().nextInt(5);
                LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(j));
                c = j;
                System.out.println("c第" + i + "次初始化 = " + j);
                //等待
                try {
                    System.out.println(Thread.currentThread().getName() + ":await");
                    cyclicBarrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
                System.out.println("任务:a - b + c + d = " + (a - b + c + d));
            }
        }
    }

    /**
     * 回调任务:等到变量都初始化完毕之后执行:d = a + b + c
     */
    static class HookMethod implements Runnable {
        @Override
        public void run() {
            d = a + b + c;
            System.out.println("\n回调任务:d = a + b + c = " + d);
        }
    }
}

4 CyclicBarrier的总结

CyclicBarrier和之前学习CountDownLatch有些相似,但是又有区别:

  1. CountDownLatch和CyclicBarrier都能够实现线程之间的等待,只不过它们侧重点不同。CountDownLatch一般用于某个或者某一批线程等待若干个其他线程执行完任务之后,它才执行;而CyclicBarrier一般用于一组线程互相等待至某个状态,然后这一组线程再同时执行;
  2. 同一个线程中调用多次CountDownLatch的countDown方法,计数器就会减去多次;而同一线程中调用多次cyclicBarrier的await方法,还是只会算作一条线程到达当前屏障,因为调用一次await之后就会等待,而在所有线程都到达屏障之后,屏障开放并且重置,后续的await方法将算作在新屏障上的等待!
  3. CountDownLatch的计数器只能使用一次,而CyclicBarrier的屏障可以使用reset()方法重置,也会自动重置,所以CyclicBarrier能处理更为复杂的业务场景。
  4. CountDownLatch是使用原始的AQS框架实现的,而CyclicBarrier使用的则是更加高级的组件ReentrantLock和Condition,但是追根溯源,这两个组件也是依赖AQS实现的。

和CountDownLatch一样,CyclicBarrier的源码看起来非常简单,那是因为复杂的线程等待、唤醒机制都被ReentrantLock和Condition实现了,如果想要真正了解CyclicBarrier的原理,那么ReentrantLock和Condition的实现必须要了解,同时AQS作为JUC中的锁和同步组件的实现基石,AQS也有必要了解。实际上如果学会了AQS,那么JUC中的锁就很简单了!

相关文章:
  AQS:JUC文章整理
  ReadWriteLock:Java ReadWriteLock读写锁的源码深度解析与应用【一万字】

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

刘Java

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

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

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

打赏作者

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

抵扣说明:

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

余额充值