Java多线程|系统梳理多线程构建与调优策略

==========================【导读】[开始]========================== 

        工作中实践到了多线程与高并发应用,也踩了一些沉重的坑。
万丈高楼起于垒土,学习与总结+工作实践不可相离。分三部分总结这块知识。
知识体系详见第一张思维导图。本篇主题“多线程构建与调优”。

==========================【导读】[结束]========================== 

1 并发集合

(1)线程安全集合

线程安全库中的容器类提供了以下的安全发布保证:

  • 通过将一个键或者值放入 Hashtable 或者 ConcurrentHashMap 中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)。
  • 通过将某个元素放入 Vector、CopyOnWriteArrayList、CopyOnWriteArraySet 中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程。
  • 通过将某个元素放入 ConcurrentLinkedQueue 或实现 BlockingQueue 的类(ArrayBlockingQueue、PriorityBlockingQueue、SynchronousQueue、DelayQueue 等)中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。
  • 类库中的其他数据传递机制(如Future、Exchanger) 同样能实现安全发布。
  • 通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:public static Holder holder = new Holder(42) ;
  • 静态初始化器由 JVM 在类的初始化阶段执行。由于在 JVM 内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布。

(2)容器之迭代器与 ConcurrentModificationException

  • 对容器类进行迭代的标准方式都是使用 Iterator。在设计同步容器类的迭代器时并没有考虑到并发修改的问题,发现容器在迭代过程中被修改时,就会抛出一个ConcurrentModificationException 异常。
  • 内部实现机制是将计数器的变化与容器关联起来:如果在迭代期间计数器被修改,那么 hasNext 或 next 将抛出 ConcurrentModificationException。如果不希望在迭代期间对容器加锁,那么一种替代方法就是“克隆”容器,并在副本上进行迭代。由于副本被封闭在线程内,因此其他线程不会在迭代期间对其进行修改,这样就避免了抛出ConcurrentModificationException (在克隆过程中仍然需要对容器加锁)。
  • 虽然加锁可以防止迭代器抛出 ConcurrentModificationException 但必须要记住在所有对共享容器进行迭代的地方都需要加锁。实际情况要更加复杂,容器的 hashCode、equals、toString()等方法也会间接地执行迭代操作,containsAll、removeAll 和 retainAll 等方法,以及把容器作为参数的构造函数,都会对容器进行迭代。所有这些间接的迭代操作都可能抛出 ConcurrentModificationException。

(3)实例之-HashMap同步容器

ConcurrentHashMap、ConcurrentSkipListMap,以及通过synchronizedMap 来包装的
HashMap 和TreeMap(对集合整体加一个独占锁synchronized实现同步)。前两种Map是线程
安全的,而后两个Map通过同步封装器来保证线程安全。
    前两者的吞吐量会随着线程数量的增加而增加。
    HashTable也是线程安全(加一个 独占锁synchronized实现同步)。
    HashMap可以允许存在一个为null的key和任意个为null的value,但是HashTable中的key和value都不允许为null。

(4)实例之-Array同步容器

    CopyOnWriteArayList用于替代同步List,在某些情况下它提供了更好的并发性能,并且在迭代期间不
需要对容器进行加锁或复制。(类似地,CopyOnWriteArraySet 的作用是替代同步Set.)“写入时复制”容
器的迭代器保留一个指向底层基础数组的引用,这个数组当前位于迭代器的起始位置,由于它不会被修改,
因此在对其进行同步时只需确保数组内容的可见性。

2 工作队列

(1)阻塞队列(生产者-消费者模式)

LinkedBlockingQueue ,ArrayBlockingQueue ,PriorityBlockingQueue ,SynchronousQucue。
    阻塞队列提供了可阻塞的put和take方法,以及支持定时的offer和poll方法。如果队列已经满了,
那么put方法将阻塞直到有空间可用;如果队列为空,那么take方法将会阻塞直到有元素可用。队列可
以是有界的也可以是无界的,无界队列永远都不会充满,因此无界队列.上的put方法也永远不会阻塞。
    阻塞队列支持生产者一消费者这种设计模式。
    在类库中包含了BlockingQucue 的多种实现,其中,LinkedBlockingQueue 和ArrayBlockingQueue 
是FIFO队列,二者分别与LinkedList 和ArrayList 类似,但比同步List拥有更好的并发性能。
PriorityBlockingQueue 是一个按优先级排序的队列,当你希望按照某种顺序而不是FIFO 来处理元素时,
这个队列将非常有用。正如其他有序的容器一样,PriorityBlockingQueue 既可以根据元素的自然顺序来
比较元素(如果它们实现了Comparable 接口),也可以使用Comparator来比较。
    最后-一个BlockingQueue 实现是SynchronousQucue,实际上它不是一个真正的队列,因为它不会为队
列中元素维护存储空间。与其他队列不同的是,它维护一组线程,这些线程在等待着把元素加入或移除队列。
因为SynchronousQueue没有存储功能,因此put和take会一直阻塞,直到有另一个线程已经准备好参与到交
付过程中。仅当有足够多的消费者,并且,总是有一个消费者准备好获取交付的工作时,才适合使用同步队列。

(2)双端队列与工作密取

 ArrayDeque ,LinkedBlockingDeque
    Deque 和BlockingDeque,它们分别对Queue和BlockingQueue进行了扩展。Deque 是一个双端队列,
实现了在队列头和队列尾的高效插入和移除。具体实现包括ArrayDeque 和LinkedBlockingDeque.
    正如阻塞队列适用于生产者一消费者模式,双端队列同样适用于另一种相关模式,即工作密取
(Work Stealing).在生产者一消费者设计中,所有消费者有一个共享的工作队列,而在工作密取设计中,
每个消费者都有各自的双端队列。如果一个消费者完成了自己双端队列中的全部工作,那么它可以从其
他消费者双端队列末尾秘密地获取工作。密取工作模式比传统的生产者一消费者模式具有更高的可伸缩
性,这是因为工作者线程不会在单个共享的任务队列上发生竟争。在大多数时候,它们都只是访问自己
的双端队列,从而极大地减少了竞争。当工作者线程需要访问另一个队列时,它会从队列的尾部而不是
从头部获取工作,因此进一步降低了队列上的竞争程度。

3 同步工具

    同步工具包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch)。
    同步工具类可以是任何一个对象,只要它根据其自身的状态来协调线程的控制流。阻塞队列可以作为同步工具类,
其他类型的同步工具类还包括信号量(Semaphore)、栅栏(Barrier)以及闭锁(Latch).  所有的同步工具类都包含一些
特定的结构化属性:它们封装了一些状态,这些状态将决定执行同步工具类的线程是继续执行还是等待,此外还提供了
一些方法对状态进行操作,以及另些方法用于高效地等待同步工具类进人到预期状态。

(1)Semaphore

    信号量(Semaphore) 用来控制同时访问某个特定资源的操作数量,或者同时执行某个指定操作的数量。 
计数信号量还可以用来实现某种资源池,或者对容器施加边界。
    Semaphore中管理着一组虚拟的许可(permit),许可的初始数量可通过构造函数来指定。在执行操作时可
以首先获得许可(只要还有剩余的许可),并在使用以后释放许可。如果没有许可,那么acquire将阻塞直到有
许可(或者直到被中断或者操作超时).release 方法将返同一个许可给信号量。
    Semaphore 可以用于实现资源池,例如数据库连接池。也可以使用Semaphore将任何一种容器变成有界阻塞容器。

(2)CyclicBarrier

    栅栏(Barrier) 类似于闭锁,它能阻塞一组线程直到某个事件发生。栅栏与闭锁的关键区别在于,所有线程必须
同时到达栅栏位置,才能继续执行。闭锁用于等待事件,而栅栏用于等待其他线程。栅栏用于实现一些协议,例如几
个家庭决定在某个地方集合:“所有人6:00 在麦当劳碰头,到了以后要等其他人,之后再讨论下一步要做的事情。”
    CyclicBarrier 可以使一定数量的参与方反复地在栅栏位置汇集,它在并行迭代算法中非常有用。

(3)CountDownLatch

    闭锁可以延迟线程的进度直到其到达终止状态。闭锁的作用相当于一扇门: 在闭锁到达结束状态之前,这扇门一直是关闭的,
并且没有任何线程能通过,当到达结束状态时,这扇门会打开并允许所有的线程通过。
    CountDownLatch 是一种灵活的闭锁实现,它可以使一个或多个线程等待一组事件发生。闭锁状态包括一个计数器,该计数器
被初始化为一个正数,表示需要等待的事件数量。countDown方法递减计数器,表示有一个事件已经发生了,而await方法等待计数
器达到零,这表示所有需要等待的事件都已经发生。如果计数器的值非零, 那么await 会一直阻塞直到计数器为零,或者等待中
的线程中断,或者等待超时。

4 线程池框架及线程池配置与调优

(1)无线程池弊端

    线程生命周期的开销非常高。线程的创建与销毁额外开销大。
    资源消耗不可控。
    稳定性不可控。

(2)Executor接口

    Executor接口,提供了一种标准的方法将任务的提交过程与执行过程解耦开来,并用Runnable 来表示任务。
Executor的实现还提供了对生命周期的支持,以及统计信息收集、应用程序管理机制和性能监视等机制。Executor
基于生产者一消费者模式,提交任务的操作相当于生产者,执行任务的线程则相当于消费者。

(3)线程池构建影响因素考量

(1)在什么(What)线程中执行任务?
(2)任务按照什么(What)顺序执行(FIFO、LIFO、优先级) ?
(3)有多少个(How Many)任务能并发执行?
(4)在队列中有多少个(How Many)任务在等待执行?
(5)如果系统由于过载而需要拒绝一个任务,那么应该选择哪一个( Which)任务?
      另外,如何(How)通知应用程序有任务被拒绝?
(6)在执行一个任务之前或之后,应该进行哪些(What) 动作?

(4)Executors工厂类创建线程池

(1)newFixedThreadPool。newFixedThreadPool将创建一个固定长度的线程池,每当提交一个任务时就创建一个线程,
   直到达到线程池的最大数量,这时线程池的规模将不再变化(如果某个线程由于发生了未预期的Exception而结束,那么
   线程池会补充一个新的线程)。
    newFixedThreadPool 工厂方法将线程他的基本大小和最大大小设置为参数中指定的值,而且创建的线程池不会超时。
(2)newCachedThreadPool。newCachedThreadPool 将创建一个可缓存的线程池,如果线程池的当前规模超过了处理需求
  时,那么将回收空闲的线程,而当需求增加时,则可以添加新的线程,线程池的规模不存在任何限制。newCachedThreadPool
  工厂方法将线程池的最大大小设置为Integer.MAX.VALUE,而将基本大小设置为零,并将超时设置为1分钟,这种方法创建出来
  的线程池可以被无限扩展,并且当需求降低时会自动收缩。
(3)newSingleThreadExecutor。newSingleThreadExecutor是一个单线程的Executor,它创建单个工作者线程来执行任务,
   如果这个线程异常结束,会创建另一个线程来替代。newSingleThreadExecutor能确保依照任务在队列中的顺序来串行执行
   (例如FIFO、LIFO、优先级)。
(4)newScheduledThreadPool。 newScheduledThreadPool 创建了一个固定长度的线程池,而且以延迟或定时的方式来执行任务。
(5)newWorkStealingPool。返回一个ForkJoinPool类型的 executor。工作中暂未使用过。

(5)线程池生命周期管理

    为了解决执行服务的生命周期问题,Executor 扩展了ExecutorService 接口,添加了一些用于生命周期管理的方法。
public  interface ExecutorService extends Executor {
	vold shutdown() ;
	List<Runnable> shutdownNow( );
	boolean isShutdown() ;
	boolean isTerminated() ;
	boolean awaitTermination(long timeout,TimeUnit unit) throws InterruptedException;
//...
}
    ExecutorService 的生命周期有3 种状态:运行、关闭和已终止。
    ExecutorService 在初始创建时处于运行状态。
    shutdown 方法将执行平缓的关闭过程: 不再接受新的任务,同时等待已经提交的任务执行完成一包括那些还未开始执行的任务。
    shutdownNow 方法将执行粗暴的关闭过程: 它将尝试取消所有运行中的任务,并且不再启动队列中尚未开始执行的任务。

(6)携带结果的任务Callable与Future

    Executor框架使用Runnable作为其基本的任务表示形式。Runnable 是一种有很大局限的抽象,不能返回一个值
或抛出一个受检查的异常。Callable 是一种更好的抽象:它认为主人口点(即call)将返回一个值,并可能抛出一个异常。
    Executor执行的任务有4个生命周期阶段:创建、提交、开始和完成。由于有些任务可能要执行很长的时间,因此通
常希望能够取消这些任务。在Executor框架中,已提交但尚未开始的任务可以取消,但对于那些已经开始执行的任务,
只有当它们能响应中断时,才能取消。取消一个已经完成的任务不会有任何影响。
    Future表示一个任务的生命周期,并提供了相应的方法来判断是否已经完成或取消,以及获取任务的结果和取消任务等。
ExecutorService中的所有submit方法都将返回一个Future,从而将一个Runnable或Callable提交给Executor,并得到一
个Future用来获得任务的执行结果或者取消任务。

(8)灵活定制你的线程池ThreadPoolExecutor

 
    ThreadPoolExcutor是一个灵话的、稳定的线程池,允许进行各种定制。内部是由Executors中的newCachedThreadPool,
newFixedThreadPool 和newScheduledThreadExecuto 等工厂方法返回的。

  public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler) {
  //......
  }
    (1)线程池定制参数[大小及时间参数]:
    线程池的基本大小(corePoolSize),最大大小(maximumPoolSize)以及存活时间(keepAliveTime)
等因素共同负责线程的创建与销毁。基本大小也就是线程他的目标大小,即在没有任务执行时日线程
池的大小,并且只有在工作队列满了的情况下才会创建超出这个数量的线程。线程池的最大大小表示
可同时活动的线程数量的上限。如果某个线程的空闲时间超过了存话时间,那么将被标记为可回收的,
并且当线程池的当前大小超过了基本大小时,这个线程将被终止。
    (2)线程池定制参数[线程池工作队列选择]:
 
   3种工作队列。无界队列、有界队列和同步移交。
无界队列使用场景:
    newFixedThreadPool和newSingleThreadExecutor在默认情况下将使用一个无界的LinkedBlockingQueue.
有界队列使用场景:
  更稳妥的资源管理策略是使用有界队列,例如ArrayBlockingQucuc.有界的LinkedBlockingQueue,PriorityBlockingQueue.
有界队列有助于避免资源耗尽的情况发生,但它又带来了新的问题: 当队列填满后,新的任务该怎么办? (有许多饱和策略可以解决这个问题)。
同步移交使用场景:
    对于非常大的或者无界的线程池,可以通过使用SynchronousQueue来避免任务排队,以及直接将任务从生产者移交给工作者线程。
SynchronousQueue不是一个真正的队列,而是一种在线程之间进行移交的机制。只有当线程池是无界的或者可以拒绝任务时,SynchronousQueue
才有实际价值。在newCachedThreadPool工厂方法中就使用了SynchronousQueue.
    (3)线程池定制参数[饱和策略 ]:
    当有界队列被填满后,饱和策略开始发挥作用.ThreadPoolExecutor的饱和策略可以通过调用setRejectedExecutionHandler来修改。
(如果某个任务被提交到一个已被关闭的Executor时,也会用到饱和策略。)JDK提供了几种不同的RejectedExecutionHandler实现,每种
实现都包含有不同的饱和策略:AbortPolicy,CallerRunsPolicy,DiscardPolicy和DiscardOldestPolicy.
[1]AbortPolicy
  “中止策略”是默认的饱和策略,该策略将抛出未检查的RectedExecutionException.调用者可以捕获这个异常,
然后根据需求编写自己的处理代码。
[2]DiscardPolicy
    “抛弃策略"当新提交的任务无法保存到队列中等待执行时,会悄悄抛弃该任务。
[3]DiscardOldestPolicy
    “抛弃最旧策略"则会抛弃下一个将被执行的任务,然后尝试重新提交新的任务。(如果工作队列是一个优先队列,那么“抛弃最
旧的"策略将导致抛弃优先级最高的任务。因此最好不要将“抛弃最旧的"饱和策略和优先级队列放在一起使用.)
[4]CallerRunsPolicy
  “调用者运行策略" 实现了一种调节机制,该策略既不会抛弃任务,也不会抛出异常,而是将某些任务回退到调用者,从而降低
新任务的流量。它不会在线程池的某个线程中执行新提交的任务,而是在一个调用了execute的线程中执行该任务。
    (4)线程池定制参数[线程工厂]:
    每当线程池需要创建一个线程时,都是通过线程工厂方法来完成的。默认的线程工厂方法将创建一个新的、非守护的线程,
并且不包含特殊的配置信息。通过指定一个线程工厂方法,可以定制线程池的配置信息。在ThreadFactory中只定义了一个方法
newThread,每当线程池需要创建一个新线程时都会调用这个方法。

(9)扩展ThreadPoolExecutor

    ThreadPoolExecutor是可扩展的,它提供了几个可以在子类中改写的方法:beforeExecute.afterExecute和
terminated,这些方法可以用于扩展ThreadPoolExecutor的行为。
    在执行任务的线程中将调用beforeExecute和afterExecute等方法,在这些方法中还可以添加日志、计时、
监视或统计信息收集的功能。无论任务是从run中正常返回,还是抛出一个异常而返回,afterExecute都会被
调用。(如果任务在完成后带有一个Error,那么就不会调用afterExecute,)如果beforcExecute抛出一个
RuntimeException,那么任务将不被执行,并且afterExecute也不会被调用。
    在线程池完成关闭操作时调用terminated,也就是在所有任务都已经完成并且所有工作者线程也已经关闭后。
terminated 可以用来释放Executor在其生命周期里分配的各种资源,此外还可以执行发送通知、记录日志或者
收集fnalize统计信息等操作。

(10)线程引入的开销

    上下文切换,内存同步开销,阻塞
1 上下文切换
    如果主线程是唯一的线程,那么它基本上不会被调度出去。如果可运行的线程数大于CPU的数量,那么操作
系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU.这将导致一次上下文切换,在这个
过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。
2 内存同步
    同步操作的性能开销包括多个方面。在synchronized和volatile提供的可见性保证中可能会使用一些特殊指
令,即内存栅栏(MemoryBarrier).内存栅栏可以刷新缓存,使缓存无效,刷新硬件的写缓冲,以及停止执行管道。
内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中,大多数操作
都是不能被重排序的。
3 阻塞
    当在锁上发生竞争时,竞争失败的线程肯定会阻塞。JVM在实现阻塞行为时,可以采用自旋等待
(Spin-Waiting,指通过循环不断地尝试获取锁,直到成功)或者通过操作系统挂起被阻塞的线程。这两种方式的
效率高低,要取决于上下文切换的开销以及在成功获取锁之前需要等待的时间。如果等待时间较短,则适合采用
自旋等待方式,而如果等待时间较长,则适合采用线程挂起方式。有些JVM将根据对历史等待时间的分析数据在
这两者之间进行选择,但是大多数JVM在等待锁时都只是将线程桂起。

(11)长作业任务阻塞线程池调优建议

    如果任务阻塞的时间过长,那么即使不出现死锁,线程池的响应性也会变得糟糕。执行时间较长的任务不仅会造
成线程池堵塞,甚至还会增加执行时间较短任务的服务时间。如果线程池中线程的数量远小于在稳定状态下执行时间
较长任务的数量,那么到最后可能所有的线程都会运行这些执行时间较长的任务,从而影响整体的响应性。
    有一项技术可以缓解执行时间较长任务造成的影响,即限定任务等待资源的时间,而不要无限制地等待。在平台
类库的大多数可阻塞方法中,都同时定义了限时所本和无限时版本,例如Thread.join, BlockingQucue.put,
CountDownLatch.await以及Selector.select等。如果等待超时,那么可以把任务标识为失败,然后中止任务或者将任
务重新放回队列以便随后执行。如果在线程池中总是充满了被阻塞的任务,那么也可能表明线程池的规模过小,考虑
动态调整线程池的数量。
    也可以考虑将不同弄作业时长的任务进行线程池隔离处理。

(12)AQS

    AQS(AbstractQueuedSynchronizer)是其他许多同步类的基类。AQS是一个用于构建锁和同步器的框架,
许多同步器都可以通过AQS 很容易并且高效地构造出来。不仅ReentrantLock和Semaphore是基于AQS构建的,
还包括CountDownLatch.ReentrantReadWriteLock.SynchronousQueue 和FutureTask.
    基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。"获取"操作是一种依赖
状态的操作,井且通常会阻塞。"释放"并不是一个可阻塞的操作,当执行“释放" 操作时,所有在请求时被
阻塞的线程都会开始执行。
    如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了
一个整数状态信息,可以通过getState,setState 以及compareAndSetState 等protected类型方法来进行操作。  AQS(AbstractQueuedSynchronizer)是其他许多同步类的基类。AQS是一个用于构建锁和同步器的框架,
许多同步器都可以通过AQS 很容易并且高效地构造出来。不仅ReentrantLock和Semaphore是基于AQS构建的,
还包括CountDownLatch.ReentrantReadWriteLock.SynchronousQueue 和FutureTask.
    基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。"获取"操作是一种依赖
状态的操作,井且通常会阻塞。"释放"并不是一个可阻塞的操作,当执行“释放" 操作时,所有在请求时被
阻塞的线程都会开始执行。
    如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了
一个整数状态信息,可以通过getState,setState 以及compareAndSetState 等protected类型方法来进行操作。

(13)JVM到底能跑多少线程?

    Java多线程-线程池数量设置多少?JVM能跑多少线程?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
of the student you want to query: "); scanf("%s", target); for (int i = 0; i < num;Java线程和Android线程本质上是相同的,都是使用Java语言的线程机制来实现的。Java线程是在Java虚拟机上实现的,而Android线程是在Android操作系统上实现的 i++) { if (strcmp(students[i].id, target) == 0) { printf("Name\tID\tSchool\t。 Java多线程和Android多线程也是基本相同的。它们都支持多线程并发执行Score\tAddress\tPhone\n"); printf("%s\t%s\t%s\t%.1f\t%s\t%s\n", students[i].name,可以提高程序的执行效率和响应速度。Java多线程和Android多线程都是通过创建多个线程并发执行来实现的,可以使用Java中的Thread类或者Android中的AsyncTask类来创建线, students[i].id, students[i].school, students[i].score, students[i].address, students[i].phone); return; 程。 但是,由于Android操作系统是基于Linux内核的,所以Android线程的实现方式与Java } } printf("Cannot find the student with id %s\n", target); } // 添加专业信息 void add线程有一些不同。例如,在Android中,UI线程(也称为主线程)用于处理用户交Major(Major* majors, int* num) { printf("Enter code: "); scanf("%s", majors[*num].code); 互事件,而在Java中没有这个概念。另外,Android中还有一些特殊的线程类型,例如HandlerThread和IntentService等,用于处理UI事件或者后台操作。 总的来说,Java线程和Android printf("Enter name: "); scanf("%s", majors[*num].name); printf("Enter subject: "); scanf("%s线程基本相同,但是在实现方式上有一些不同,需要根据具体的应用场景来选择适合的线程类型。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

不甩锅的码农

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

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

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

打赏作者

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

抵扣说明:

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

余额充值