第14章 并发

AI权益加码!Claude Code、Cursor等20+工具免费用! 购周边限时加赠Coding Plan Lite,畅享主流AI工具!学习进阶更高效! 阅读详情

第14章 并发

并发执行

  • 并发执行的进程数目并不是由 CPU 数目制约的。
  • 操作系统将 CPU 的时间片分配给每一个进程,给人并行处理的感觉。

多线程程序

  • 一个程序同时执行多个任务,每一个任务称为一个线程 (thread),它是线程控制的简称。
  • 可以同时运行一个以上线程的程序称为多线程程序 ( multithreaded )。

多进程与多线程区别

  • 本质的区别在于每个进程拥有自己的一整套变量,而线程则共享数据。

14.1 什么是线程

14.1.1 使用线程给其他任务提供机会

单线程执行任务简单过程

Runnable r = () -> { task code };
Thread t = new Thread(r);
t.start()
  • 将任务代码移到实现了 Runnable 接口的类的 run 方法中。
  • 由 Runnable 创建一个 Thread 对象。
  • 启动线程。

Runnable 接口

public interface Runnable {
	void run()}

构建Thread 子类定义线程

class MyThread extends Thread {
	public void run() {
		task code
	}
}
  • 这种方法已不再推荐,应该将要并行运行的任务与运行机制解耦合。
  • 如果有很多任务,要为每个任务创建一个独立的线程所付出的代价太大了。

直接调用 run 方法

  • 不要调用 Thread 类或 Runnable 对象的 run 方法。直接调用 run 方法,只会执行同一个线程中的任务,而不会启动新线程。
  • 应该调用 Thread.start 方法。这个方法将创建一个执行 ran 方法的新线程。

14.2 中断线程

终止线程

  • 当线程的 run 方法执行方法体中最后一条语句后,并经由执行 return 语句返冋时,或者出现了在方法中没有捕获的异常时,线程将终止。
  • 在 Java 的早期版本中, 还有一个 stop 方法,其他线程可以调用它终止线程。但是,这个方法现在已经被弃用了。

interrupt 方法

  • 没有可以强制线程终止的方法。然而,interrupt 方法可以用来请求终止线程。
  • 当对一个线程调用 interrupt 方法时,线程的中断状态将被置位。

这是每一个线程都具有的 boolean 标志。每个线程都应该不时地检査这个标志,以判断线程是否被中断。

  • 要想弄清中断状态是否被置位,首先调用静态的 Thread.currentThread 方法获得当前线程,然后调用 islnterrupted 方法:
while (!Thread.currentThread().islnterrupted() && more work to do) {
	do more work
}

InterruptedException 异常

  • 如果线程被阻塞,就无法检测中断状态。这是产生 InterruptedExceptioii 异常的地方。
  • 当在一个被阻塞的线程 (调用 sleep 或 wait) 上调用 interrupt 方法时,阻塞调用将会被 Interrupted Exception 异常中断。

存在不能被中断的阻塞 I/O 调用,应该考虑选择可中断的调用。

响应中断

  • 没有任何语言方面的需求要求一个被中断的线程应该终止。
  • 中断一个线程不过是引起它的注意。被中断的线程可以决定如何响应中断。

某些线程是如此重要以至于应该处理完异常后,继续执行,而不理会中断。但是,更普遍的情况是,线程将简单地将中断作为一个终止的请求。

Runnable r = () -> {
	try {
		while (!Thread.currentThread().islnterrupted0 && more work to do) {
			do more work
		}
	}catch(InterruptedException e) {
		// thread was interr叩ted during sleep or wait
	}finally {
		cleanup,ifrequired
	}
	// exiting the run method terminates the thread
};

中断后调用sleep

  • 如果在中断状态被置位时调用 sleep 方法,它不会休眠。相反,它将清除这一状态 (!) 并拋出 InterruptedException。
  • 如果在每次工作迭代之后都调用 sleep 方法 (或者其他的可中断方法) ,islnterrupted 检测既没有必要也没有用处。

因此,如果你的循环调用 sleep,不会检测中断状态。相反,要如下所示捕获 InterruptedException 异常。

区别interrupted 和 islnterrupted

  • interrupt方法是一个实例方法,用来中断一个线程。
  • interrupted 方法是一个静态方法,它检测当前的线程是否被中断。而且,调用 interrupted 方法会清除该线程的中断状态。
  • islnterrupted 方法是一个实例方法,可用来检验是否有线程被中断。调用这个方法不会改变中断状态。

方法中处理 InterruptedException 异常

void mySubTask() {
	try { sleep(delay); }
	catch (InterruptedException e) {} // Don't ignore!
}
//调用者可以对其进行检测。
void mySubTask() {
	try { sleep(delay); }
	catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
//调用者 (或者,最终的 run 方法) 可以捕获这一异常。
void mySubTask() throws InterruptedException {
	sleep(delay);
}
  • 在很多发布的代码中会发现 InterruptedException 异常被抑制在很低的层次上 (捕获不处理)。
  • 不要这样做!如果不认为在 catch 子句中做这一处理有什么好处的话,仍然有两种合理的选择:
    • 在 catch 子句中调用 Thread.currentThread().interrupt() 来设置中断状态。于是,调用者可以对其进行检测。
    • 或者,更好的选择是,用 throws InterruptedException 标记你的方法,不采用 try语句块捕获异常。于是,调用者 (或者,最终的 run 方法) 可以捕获这一异常。

14.3 线程状态

线程6种状态

  • New (新创建)
  • Runnable (可运行)
  • Blocked (被阻塞)
  • Waiting (等待)
  • Timed waiting (计时等待)
  • Terminated (被终止)

要确定一个线程的当前状态,可调用 getState 方法。

14.3.1 新创建线程

  • 当用 new 操作符创建一个新线程时,如 newThread®,该线程还没有开始运行。

14.3.2 可运行线程

  • 一旦调用 start 方法,线程处于 runnable 状态。
  • 一个可运行的线桿可能正在运行也可能没有运行,这取决于操作系统给线程提供运行的时间。

Java 的规范说明没有将它作为一个单独状态。一个正在运行中的线程仍然处于可运行状态。

抢占式调度系统

  • 抢占式调度系统给每一个可运行线程一个时间片来执行任务。当时间片用完,操作系统剥夺该线程的运行权,并给另一个线程运行机会。
  • 当选择下一个线程时, 操作系统考虑线程的优先级。
  • 在具有多个处理器的机器上,每一个处理器运行一个线程,可以有多个线程并行运行。当然,如果线程的数目多于处理器的数目,调度器依然采用时间片机制。

14.3.3 被阻塞线程和等待线程

  • 当线程处于被阻塞或等待状态时,它暂时不活动。它不运行任何代码且消耗最少的资源。直到线程调度器重新激活它。

阻塞状态

  • 当一个线程试图获取一个内部的对象锁 (而不是 java.util.concurrent 库中的锁) ,而该锁被其他线程持有,则该线程进人阻塞状态。
  • 当所有其他线程释放该锁,并且线程调度器允许本线程持有它的时候,该线程将变成非阻塞状态。

等待状态

  • 当线程等待另一个线程通知调度器一个条件时,它自己进入等待状态。
  • 在调用 Object.wait 方法或 Thread.join 方法,或者是等待 java.util.concurrent 库中的 Lock 或 Condition 时,就会出现这种情况。

计时状态

  • 有几个方法有一个超时参数。调用它们导致线程进入计时等待 (timed waiting) 状态。
  • 这一状态将一直保持到超时期满或者接收到适当的通知。
  • 带有超时参数的方法有Thread.sleep 和 Object.wait、Thread.join、Lock.tryLock 以及 Condition.await 的计时版。

14.3.4 被终止的线程

线程终止的两个原因

  • 因为 run 方法正常退出而自然死亡。
  • 因为一个没有捕获的异常终止了 run 方法而意外死亡。

特别是,可以调用线程的 stop 方法杀死一个线程。该方法抛出 ThreadDeath 错误对象,由此杀死线程。

线程状态转换
线程状态

  • 当一个线程被阻塞或等待时 (或终止时) ,另一个线程被调度为运行状态。
  • 当一个线程被重新激活 (例如,因为超时期满或成功地获得了一个锁) ,调度器检查它是否具有比当前运行线程更高的优先级。
  • 如果是这样,调度器从当前运行线程中挑选一个,剥夺其运行权,选择一个新的线程运行。

14.4 线程属性

14.4.1 线程优先级

优先级

  • 在 Java 程序设计语言中,每一个线程有一个优先级。默认情况下,一个线程继承它的父线程的优先级。
  • 可以用 setPriority 方法提高或降低任何一个线程的优先级。

可以将优先级设置为在 MIN_PRIORITY (在 Thread 类中定义为 1 ) 与 MAX_PRIORITY (定义为 10 ) 之间的
任何值。NORM_PRIORITY 被定义为 5。

优先级高度依赖于系统

  • 每当线程调度器有机会选择新线程时,它首先选择具有较高优先级的线程。
  • 当虚拟机依赖于宿主机平台的线程实现机制时,Java 线程的优先级被映射到宿主机平台的优先级上,优先级个数也许更多,也许更少。

不同系统优先级

  • Windows 有 7 个优先级别。一些 Java 优先级将映射到相同的操作系统优先级。
  • 在 Oracle 为 Linux 提供的 Java 虚拟机中,线程的优先级被忽略一所有线程具有相同的优先级。

不要依赖于优先级

  • 不要将程序构建为功能的正确性依赖于优先级。
  • 如果有几个高优先级的线程没有进入非活动状态,低优先级的线程可能永远也不能执行。

每当调度器决定运行一个新线程时,首先会在具有高优先级的线程中进行选择,尽管这样会使低优先级的线程完全饿死。

14.4.2 守护线程

守护进程

  • 可以通过调用 t.setDaemon(true) 将线程转换为守护线程 (daemon thread)。
  • 守护线程的唯一用途是为其他线程提供服务。
  • 当只剩下守护线程时,虚拟机就退出了,由于如果只剩下守护线程,就没必要继续运行程序了。
  • 守护线程应该永远不去访问固有资源,如文件、数据库,因为它会在任何时候甚至在一个操作的中间发生中断。

14.4.3 未捕获异常处理器

run方法处理异常

  • 线程的 run方法不能抛出任何受查异常,但是,非受査异常会导致线程终止。
  • 但是,不需要任何 catch子句来处理可以被传播的异常。相反,就在线程死亡之前,异常被传递到一个用于未捕获异常的处理器。

未捕获异常的处理器

//这个接口只有一个方法
void uncaughtException(Thread t, Throwable e)
  • 该处理器必须属于一个实现 Thread.UncaughtExceptionHandler 接口的类。
  • 如果不安装默认的处理器,默认的处理器为空。
  • 但是,如果不为独立的线程安装处理器,此时的处理器就是该线程的 ThreadGroup 对象。

ThreadGroup 类

  • ThreadGroup 类实现 Thread.UncaughtExceptionHandler 接口。
  • 它的 uncaughtException 方法做如下操作:
    • 如果该线程组有父线程组,那么父线程组的 uncaughtException 方法被调用。
    • 否则,如果 Thread.getDefaultExceptionHandler 方法返回一个非空的处理器,则调用该处理器。
    • 否则,如果 Throwable 是 ThreadDeath 的一个实例,什么都不做。
    • 否则,线程的名字以及 Throwable 的栈轨迹被输出到 System.err 上。

替换处理器

  • 可以用 setUncaughtExceptionHandler 方法为任何线程安装一个处理器。
  • 也可以用 Thread类的静态方法 setDefaultUncaughtExceptionHandler 为所有线程安装一个默认的处理器。

替换处理器可以使用日志 API 发送未捕获异常的报告到日志文件。

14.5 同步

竞争条件

  • 根据各线程访问数据的次序,可能会产生讹误的对象。这样一个情况通常称为竞争条件 (race condition)。

14.5.1 竞争条件的一个例子

同步存取

  • 为了避免多线程引起的对共享数据的说误,必须学习如何同步存取。

例子

  • 在下面的测试程序中,模拟一个有若干账户的银行。
  • 随机地生成在这些账户之间转移钱款的交易。
  • 每一个账户有一个线程。每一笔交易中,会从线程所服务的账户中随机转移一定数目的钱款到另一个随机账户。

14.5.2 竞争条件详解

问题
同时访问

  • 当两个线程试图同时更新同一个账户的时候,这个问题就出现了。
  • 假定两个线程同时执行指令 accounts[to] += amount; 问题在于这不是原子操作。该指令可能被处理如下:
    • 将 accounts[to] 加载到寄存器。
    • 增加 amount。
    • 将结果写回 accounts[to]。
  • 现在,假定第 1 个线程执行步骤 1 和 2, 然后,它被剥夺了运行权。假定第 2 个线程被唤醒并修改了 accounts 数组中的同一项。然后,第 1 个线程被唤醒并完成其第 3 步。

这样,这一动作擦去了第二个线程所做的更新。于是,总金额不再正确。

真正的问题

  • 真正的问题是 transfer 方法的执行过程中可能会被中断。如果能够确保线程在失去控制之前方法运行完成, 那么银行账户对象的状态永远不会出现讹误。

14.5.3 锁对象

防止代码并发干扰

  • 有两种机制防止代码块受并发访问的干扰。Java语言提供一个 synchronized 关键字达到这一目的,并且 Java SE 5.0 引入了 ReentrantLock 类。
  • synchronized 关键字自动提供一个锁以及相关的“ 条件”,对于大多数需要显式锁的情况,这是很便利的。

使用 ReentrantLock 基本结构

myLock.lock(); // a ReentrantLock object
try {
	critical section
}finally {
	myLock.unlock();// make sure the lock is unlocked even if an exception is thrown
}
  • 这一结构确保任何时刻只有一个线程进人临界区。一旦一个线程封锁了锁对象,其他任何线程都无法通过 lock 语句。
  • 当其他线程调用 lock 时,它们被阻塞,直到第一个线程释放锁对象。

在finally中解锁

  • 把解锁操作括在 finally 子句之内是至关重要的。如果在临界区的代码抛出异常,锁必须被释放。否则,其他线程将永远阻塞。

不能对锁使用带资源的try语句

  • 如果使用锁,就不能使用带资源的 try 语句。
  • 首先,解锁方法名不是 close。不过,即使将它重命名,带资源的 try 语句也无法正常工作。
  • 它的首部希望声明一个新变量。但是如果使用一个锁, 你可能想使用多个线程共享的那个变量 (而不是新变量) 。

非同步线程与同步线程的比较
非同步和同步区别

  • 假定一个线程调用 transfer,在执行结束前被剥夺了运行权。
  • 假定第二个线程也调用 transfer,由于第二个线程不能获得锁,将在调用 lock 方法时被阻塞。它必须等待第一个线程完成 transfer 方法的执行之后才能再度被激活。
  • 当第一个线程释放锁时,那么第二个线程才能开始运行。

重入锁概念

  • 锁是可重入的,因为线程可以重复地获得已经持有的锁。锁保持一个持有计数 (hold count) 来跟踪对 lock 方法的嵌套调用。
  • 线程在每一次调用 lock 都要调用 unlock 来释放锁。由于这一特性,被一个锁保护的代码可以调用另一个使用相同的锁的方法。

注意临界区异常

  • 要留心临界区中的代码,不要因为异常的抛出而跳出临界区。
  • 如果在临界区代码结束之前抛出了异常,finally 子句将释放锁,但会使对象可能处于一种受损状态。

公平策略锁

  • ReentrantLock(boolean fair)构建一个带有公平策略的锁。
  • 一个公平锁偏爱等待时间最长的线程。但是,这一公平的保证将大大降低性能。
  • 只有当你确实了解自己要做什么并且对于你要解决的问题有一个特定的理由必须使用公平锁的时候,才可以使用公平锁。
  • 即使使用公平锁,也无法确保线程调度器是公平的。

如果线程调度器选择忽略一个线程,而该线程为了这个锁已经等待了很长时间,那么就没有机会公平地处理这个锁了。

14.5.4 条件对象

满足条件执行

  • 通常,线程进人临界区,却发现在某一条件满足之后它才能执行。
  • 要使用一个条件对象来管理那些已经获得了一个锁但是却不能做有用工作的线程。
  • 由于历史的原因,条件对象经常被称为条件变量 ( conditional variable )。

条件对象

  • 一个锁对象可以有一个或多个相关的条件对象。
  • 你可以用 newCondition 方法获得一个条件对象。
  • 调用 await方法,它进人该条件的等待集。
  • 调用 signalAll 方法,可通知正在等待的线程此时有可能已经满足条件,值得再次去检测该条件。
  • 调用 signal方法,则是随机解除等待集中某个线程的阻塞状态。

等待锁和条件对象区别

//等待条件
sufficientFunds.await();
//重新激活
sufficientFunds.signalAll();
  • 一旦一个线程调用 await方法,它进人该条件的等待集。当锁可用时,该线程不能马上解除阻塞。
  • 相反,它处于阻塞状态,直到另一个线程调用同一条件上的 signalAll 方法时为止。

await的调用

while(!(ok to proceed) )
	condition.await();
  • 由于无法确保该条件被满足,通常对 await 的调用应该放在循环体中。

signalAll 方法仅仅是通知正在等待的线程:此时有可能已经满足条件,值得再次去检测该条件。

死锁

  • 至关重要的是最终需要某个其他线程调用 signalAll 方法。
  • 当一个线程调用 await 时,它没有办法重新激活自身。它寄希望于其他线程。如果没有其他线程来重新激活等待的线程,它就永远不再运行了。这将导致令人不快的死锁 (deadlock) 现象。

何时调用 signalAll

  • 经验上讲,在对象的状态有利于等待线程的方向改变时调用signalAll。
  • 注意调用 signalAll 不会立即激活一个等待线程。

它仅仅解除等待线程的阻塞,以便这些线程可以在当前线程退出同步方法之后,通过竞争实现对对象的访问。

  • 另一个方法 signal,则是随机解除等待集中某个线程的阻塞状态。

这比解除所有线程的阻塞更加有效,但也存在危险。
如果随机选择的线程发现自己仍然不能运行,那么它再次被阻塞。
如果没有其他线程再次调用 signal,那么系统就死锁了。

14.5.5 synchronized 关键字

锁和条件的关键之处

  • 锁用来保护代码片段,任何时刻只能有一个线程执行被保护的代码。
  • 锁可以管理试图进入被保护代码段的线程。
  • 锁可以拥有一个或多个相关的条件对象。
  • 每个条件对象管理那些已经进入被保护的代码段但还不能运行的线程。

内部锁

public synchronized void method() {
	method body
}
//等价于
public void method() {
	this.intrinsidock.1ock();
	try{
		method body
	}finally { 
		this.intrinsicLock.unlockO; 
	}
}
  • 从 1.0 版开始,Java中的每一个对象都有一个内部锁。如果一个方法用 synchronized关键字声明,那么对象的锁将保护整个方法。

也就是说,要调用该方法,线程必须获得内部的对象锁。

  • 内部对象锁只有一个相关条件。wait 方法添加一个线程到等待集中,notifyAll /notify方法解除等待线程的阻塞状态。
//换句话说,调用 wait 或 notityAll 等价于
intrinsicCondition.await();
intrinsicCondition.signalAll();

注意几个函数区别

  • wait、notifyAll 以及 notify 方法是 Object 类的 final 方法。
  • Condition 方法必须被命名为 await、signalAll 和 signal 以便它们不会与那些方法发生冲突。

静态方法内部锁

  • 将静态方法声明为 synchronized 也是合法的。如果调用这种方法,该方法获得相关的类对象的内部锁。

内部锁局限

  • 不能中断一个正在试图获得锁的线程。
  • 试图获得锁时不能设定超时。
  • 每个锁仅有单一的条件,可能是不够的。

选择何种锁

  • 最好既不使用 Lock/Condition 也不使用 synchronized 关键字。在许多情况下你可以使用 java.util.concurrent 包中的一种机制,它会为你处理所有的加锁。
  • 如果 synchronized 关键字适合你的程序,那么请尽量使用它,这样可以减少编写的代码数量,减少出错的几率。
  • 如果特别需要 Lock/Condition 结构提供的独有特性时,才使用 Lock/Condition。

14.5.6 同步阻塞

同步阻塞

synchronized (obj) // this is the syntax for a synchronized block {
	critical section
}
  • 于是它获得 Obj 的锁。

特殊的锁

public class Bank {
	private doublet] accounts;
	private Object lock = new Object;
	public void transfer(int from, int to, int amount) {
		synchronized (lock) {// an ad-hoc lock
			accounts[from] -= amount;
			accounts[to] += amount;
		}
		System.out.print1n(..
	}
}
  • 在此,lock 对象被创建仅仅是用来使用每个 Java 对象持有的锁。

客户端锁定

  • 有时程序员使用一个对象的锁来实现额外的原子操作, 实际上称为客户端锁定 (client-side locking) 。
  • 客户端锁定是非常脆弱的,通常不推荐使用。

举例

public void transfer(Vector<Double> accounts, int from, int to, int amount) {
	synchronized (accounts) {
		accounts.setCfron, accounts.get(from)- amount);
		accounts.set(to, accounts.get(to) + amount);
	}
	Systen.out.print1n(...);
}
  • 这个方法可以工作,但是它完全依赖于这样一个事实, Vector 类对自己的所有可修改方法都使用内部锁。(然而不一定)

14.5.7 监视器概念

监视器

  • 多年来,研究人员努力寻找一种方法,可以在不需要程序员考虑如何加锁的情况下,就可以保证多线程的安全性。最成功的解决方案之一是监视器 (monitor)。

监视器特性

  • 监视器是只包含私有域的类。
  • 每个监视器类的对象有一个相关的锁。
  • 使用该锁对所有的方法进行加锁。

换句话说,如果客户端调用 obj.method(), 那么 obj对象的锁是在方法调用开始时自动获得,并且当方法返回时自动释放该锁。因为所有的域是私有的,这样的安排可以确保一个线程在对对象操作时,没有其他线程能访问该域。

  • 该锁可以有任意多个相关条件。

Java 对象不同于监视器,

  • 域不要求必须是 private。
  • 方法不要求必须是 synchronized。
  • 内部锁对客户是可用的。

14.5.8 Volatile 域

出错的可能性

  • 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
  • 编译器可以改变指令执行的顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变!

使用锁解决

  • 如果你使用锁来保护可以被多个线程访问的代码,那么可以不考虑这种问题。
  • 编译器被要求通过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当地重新排序指令。

volatile 关键字

  • volatile 关键字为实例域的同步访问提供了一种免锁机制。如果声明一个域为 volatile,那么编译器和虚拟机就知道该域是可能被另一个线程并发更新的。

举例

//使用锁,比较麻烦
private boolean done;
public synchronized boolean isDone() { return done; }
public synchronized void setDone() { done = true; }
//使用volatile
private volatile boolean done;
public boolean isDone() { return done; }
public void setDone() { done = true; }

volatile不提供原子性

public void flipDoneO { done = !done; } // not atomic
  • 不能确保翻转域中的值。不能保证读取、翻转和写入不被中断。

14.5.9 final 变置

final变量

  • 上一节已经了解到,除非使用锁或 volatile 修饰符,否则无法从多个线程安全地读取一个域。
  • 还有一种情况可以安全地访问一个共享域, 即这个域声明为 final 时。

举例

final Map<String, Double〉accounts = new HashKap<>();
  • 其他线程会在构造函数完成构造之后才看到这个 accounts 变量。
  • 如果不使用 final,就不能保证其他线程看到的是 accounts 更新后的值,它们可能都只是看到 null , 而不是新构造的 HashMap。
  • 当然,对这个映射表的操作并不是线程安全的。如果多个线程在读写这个映射表,仍然需要进行同步。

14.5.10 原子性

atomic库

  • java.util.concurrent.atomic 包中有很多类使用了很高效的机器级指令 (而不是使用锁) 来保证其他操作的原子性。

原子方式设置和增减值

public static AtomicLong nextNumber = new AtomicLong() ;
// In some thread...
long id = nextNumber.increinentAndGet():
  • Atomiclnteger 类提供了方法 incrementAndGet 和decrementAndGet,它们分别以原子方式将一个整数自增或自减。
  • incrementAndGet 方法以原子方式将 AtomicLong 自增,并返回自增后的值。

原子方式复杂的更新

//下面的代码是不可行的
public static AtonicLong largest = new AtomicLong();
// In some thread...
largest.set(Math, max(largest, get(), observed)); // Error race condition!

//循环中处理
do {
	oldValue = largest.get();
	newValue = Math.max(oldValue, observed);
} while(!largest.compareAndSet(oldValue, newValue));
//在这种情况下,循环会更次尝试,读取更新后的值,并尝试修改。最终,它会成功地用新值替换原来的值。
  • 如果希望完成更复杂的更新,就必须使用 compareAndSet 方法。
  • 实际上,应当在一个循环中计算新值和使用 compareAndSet。
  • 如果另一个线程也在更新 largest,就可能阻止这个线程更新。这样一来,compareAndSet会返回 false,而不会设置新值。
  • 这听上去有些麻烦,不过 compareAndSet 方法会映射到一个处理器操作,比使用锁速度更快。

Java8中更新

largest. updateAndGet(x -> Math.max(x, observed)) ;
//或
1argest.accumulateAndCet(observed, Math::max);
  • 在 Java SE 8 中,可以提供一个 lambda 表达式更新变量,它会为你完成更新。
  • 还有 getAndUpdate 和 getAndAccumulate 方法可以返回原值。

解决大量原子更新

  • 如果有大量线程要访问相同的原子值,性能会大幅下降,因为乐观更新需要太多次重试。
  • Java SE 8 提供了 LongAdder 和 LongAccumulator 类来解决这个问题。

LongAdder 类

  • LongAdder 包括多个变量 (加数) ,其总和为当前值。
  • 可以有多个线程更新不同的加数,线程个数增加时会自动提供新的加数。
  • 通常情况下,只有当所有工作都完成之后才需要总和的值,对于这种情况,这种方法会很高效。

LongAdder 使用

final LongAdder adder = new LongAdder();
for (...)
	pool.submit(() -> {
		while (...) {
			if (...) adder.increment();
		}
	});
	long total = adder.sum());
  • 调用 increment 让计数器自增,或者调用 add 来增加一个量,或者调用 sum 来获取总和。
  • 当然,increment 方法不会返回原值。这样做会消除将求和分解到多个加数所带来的性能提升。

LongAccumulator 类

LongAccumulator adder = new LongAccumulator(Long::sum, 0);
// In some thread...
adder.accumulate(value);
  • LongAccumulator 将这种思想推广到任意的累加操作。
  • 在构造器中,可以提供这个操作以及它的零元素。要加人新的值,可以调用 accumulate。调用 get 来获得当前值。

LongAccumulator 类实际操作

  • 在内部,这个累加器包含变量a1,a2,…,a3,每个变量初始化为零元素(这个例子中零元素为 0)。
  • 调用 accumulate 并提供值 v 时,其中一个变量会以原子方式更新为ai = ai op v, 这里 op 是中缀形式的累加操作。

例子中,调用 accumulate 会对某个 i 计 算ai = ai + v。

  • get 的结果是a1 op a2 op … op an;

例子中,这就是累加器的总和:a1 + a2 + … an;

  • 一般地,这个操作必须满足结合律和交换律。这说明, 最终结果必须独立于所结合的中间值的顺序。

14.5.11 死锁

死锁

  • 有可能会因为每一个线程要等待更多的钱款存人而导致所有线程都被阻塞。这样的状态称为死锁 (deadlock )。

本章例子死锁几种方式

  • 把每次转账至多 $1 000 的限制去掉,每次交易的金额上限设置为 2 * INITIAL_BALANCE,死锁很快就会发生。

上限太大,很快所有线程转出金额都大于自己金额。

  • 让第 i 个线程负责向第 i 个账户存钱,而不是从第 i 个账户取钱。

这样一来,有可能将所有的线程都集中到一个账户(都想它取钱)上,每一个线程都试图从这个账户中取出大于该账户余额的钱。

  • 还有一种很容易导致死锁的情况:将 signalAll 方法转换为 signal, 会发现该程序最终会挂起。

signal 方法仅仅对一个线程解锁。如果该线程不能继续运行,所有的线程可能都被阻塞。

14.5.12 线程局部变量

避免共享变量

  • 有时可能要避免共享变量,使用ThreadLocal 辅助类为各个线程提供各自的实例。

举例

  • 要为每个线程构造一个实例,可以使用以下代码:
/*例如,SimpleDateFormat 类不是线程安全的。并发访问 dateFormat 使用的内部数据结构可能会被并发的访问所破坏。当然可以使用同步,但开销很大;或者也可以在需要时构造一个局部 SimpleDateFormat 对象,不过这也太浪费了。*/
public static final ThreadLocal<SimpleDateFormat> dateFormat =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
  • 要访问具体的格式化方法,可以调用:
String dateStamp = dateFormat.get().format(new Date());
  • 在一个给定线程中首次调用 get 时,会调用 initialValue 方法。在此之后,get 方法会返回属于当前线程的那个实例。

在多个线程中生成随机数

  • java.util.Random 类是线程安全的。但是如果多个线程需要等待一个共享的随机数生成器,这会很低效。
  • 可以使用 ThreadLocal 辅助类为各个线程提供一个单独的生成器,不过 Java SE 7 还另外提供了一个便利类。只需要做以下调用:
int random = ThreadLocalRandom.currentO.nextInt(upperBound);
//ThreadLocalRandom.current() 调用会返回特定于当前线程的 Random 类实例。

14.5.13 锁测试与超时

试图申请锁

if (myLock.tryLockO) {
	// now the thread owns the lock
	try { . . . }
	finally { myLock.unlockO; }
}
	else
	// do something else
  • 线程在调用 lock 方法来获得另一个线程所持有的锁的时候,很可能发生阻塞。应该更加谨慎地申请锁。
  • tryLock 方法试图申请一个锁,在成功获得锁后返回 true,否则,立即返回false,而且线程可以立即离开去做其他事情。
  • 可以调用 tryLock 时,使用超时参数,像这样:
if (myLock.tryLock(100, TineUnit.MILLISECONDS))...

** TimeUnit类**

  • TimeUnit 是一 枚举类型,可以取的值包括 SECONDS、MILLISECONDS、MICROSECONDS和 NANOSECONDS。

中断问题

  • lock 方法不能被中断。如果一个线程在等待获得一个锁时被中断,中断线程在获得锁之前一直处于阻塞状态。如果出现死锁,那么,lock 方法就无法终止。
  • 然而,如果调用带有用超时参数的 tryLock, 那么如果线程在等待期间被中断,将抛出 InterruptedException 异常。这是一个非常有用的特性,因为允许程序打破死锁。
  • 也可以调用 locklnterruptibly 方法。它就相当于一个超时设为无限的 tryLock 方法。

等待条件时超时

  • 如果一个线程被另一个线程通过调用 signalAll 或 signal 激活或者超时时限已达到,或者线程被中断,那么 await 方法将返回。
  • 如果等待的线程被中断,await 方法将抛出一个 InterruptedException 异常。

在你希望出现这种情况时线程继续等待 (可能不太合理),可以使用 awaitUninterruptibly 方法代替 await。

14.5.14 读 / 写锁

两个锁类

  • ReentrantLock 类和 ReentrantReadWriteLock 类。
  • 如果很多线程从一个数据结构读取数据而很少线程修改其中数据的话,后者是十分有用的。
  • 在这种情况下,允许对读者线程共享访问是合适的。当然,写者线程依然必须是互斥访问的。

用读 / 写锁的必要步骤

  • 构造一个 ReentrantReadWriteLock 对象:
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
  • 抽取读锁和写锁:
private Lock readLock = rwl.readLock();
private Lock writeLock = rwl.writeLock();
  • 对所有的获取方法加读锁:
public double getTotalBalance() {
	readLock.lock()try { . . . }
	finally { readLock.unlock(); }
}
  • 对所有的修改方法加写锁:
public void transfer(. . .) {
	writeLock.lock();
	try { . . . }
	finally { writeLock.unlock(); }
}

readLock( )和writeLock( )

  • readLock( ) 得到一个可以被多个读操作共用的读锁, 但会排斥所有写操作。
  • writeLock( ) 得到一个写锁,排斥所有其他的读操作和写操作。

14.5.15 为什么弃用 stop 和 suspend 方法

stop 方法和suspend 方法

  • 初始的 Java 版本定义了一个 stop 方法用来终止一个线程, 以及一个 suspend 方法用来阻塞一个线程直至另一个线程调用 resume。
  • stop 和 suspend 方法有一些共同点:都试图控制一
    个给定线程的行为。

stop方法问题

  • 当线程要终止另一个线程时,无法知道什么时候调用 stop 方法是安全的,什么时候导致对象被破坏。
  • 在希望停止线程的时候应该中断线程, 被中断的线程会在安全的时候停止。

注意stop方法释放了内部锁

  • 错误说法:stop 方法被弃用是因为它会导致对象被一个已停止的线程永久锁定。
  • 从技术上讲,被停止的线程通过抛出 ThreadDeath 异常退出所有它所调用的同步方法。结果是,该线程释放它持有的内部对象锁。

suspend方法问题

  • 与 stop 不同,suspend 不会破坏对象。
  • 但是,如果用 suspend 挂起一个持有一个锁的线程, 那么,该锁在恢复之前是不可用的。
  • 如果调用suspend 方法的线程试图获得同一个锁,那么程序死锁:被挂起的线程等着被恢复,而将其挂起的线程等待获得锁。

如果想安全地挂起线程,引人一个变量 suspendRequested 并在 run 方法的某个安全的地方测试它,安全的地方是指该线程没有封锁其他线程需要的对象的地方。当该线程发现suspendRequested 变量已经设置, 将会保持等待状态直到它再次获得为止。

14.6 阻 塞 队 列

阻塞队列的方法
阻塞队列方法
方法分类

  • 如果将队列当作线程管理工具来使用,将要用到 put 和 take 方法。
  • 当试图向满的队列中添加或从空的队列中移出元素时,add、remove 和 element 操作抛出异常。
  • 当然,在一个多线程程序中,队列会在任何时候空或满, 因此,一定要使用 offer、poll 和 peek方法作为替代。这些方法如果不能完成任务,只是给出一个错误提示而不会抛出异常。

非法null值

  • poll 和 peek 方法返回空来指示失败。因此,向这些队列中插入 null 值是非法的。

超时变体

  • 还有带有超时的 offer 方法和 poll 方法的变体。
boolean success = q.offer(x, 100, TimeUnit.MILLISECONDS);
Object head = q.poll(100, TimeUnit.MILLISECONDS)

阻塞队列变种

  • 默认情况下,LinkedBlockingQueue 的容量是没有上边界的,但是,也可以选择指定最大容量。
  • LinkedBlockingDeque 是一个双端的版本。
  • ArrayBlockingQueue 在构造时需要指定容量,并且有一个可选的参数来指定是否需要公平性。

通常,公平性会降低性能,只有在确实非常需要时才使用它。

  • PriorityBlockingQueue 是一个带优先级的队列, 而不是先进先出队列。

该队列是没有容量上限,但是,如果队列是空的,取元素的操作会阻塞。

DelayQueue

//DelayQueue 包含实现 Delayed 接口的对象:
interface Delayed extends Comparable<Delayed> {
	long getDelay(TimeUnit unit);
}
  • getDelay方法返回对象的残留延迟。
  • 负值表示延迟已经结束。元素只有在延迟用完的情况下才能从 DelayQueue 移除。
  • 还必须实现 compareTo 方法。DelayQueue 使用该方法对元素进行排序。

TransferQueue 接口

  • JavaSE 7增加了一个 TransferQueue 接口,允许生产者线程等待,直到消费者准备就绪可以接收一个元素。
  • 如果生产者调用 q.transfer(item); 这个调用会阻塞,直到另一个线程将元素 (item) 删除。
  • LinkedTransferQueue 类实现了这个接口。

14.7 线程安全的集合

线保护共享数据

  • 可以通过提供锁来保护共享数据结构,但是选择线程安全的实现作为替代可能更容易些。

14.7.1 高效的映射、集和队列

高效数据类型

  • java.util.concurrent 包提供了映射、有序集和队列的高效实现:ConcurrentHashMap、ConcurrentSkipListMap 、ConcurrentSkipListSet 和 ConcurrentLinkedQueue。
  • 这些集合使用复杂的算法,通过允许并发地访问数据结构的不同部分来使竞争极小化。

size问题

  • 与大多数集合不同,size 方法不必在常量时间内操作。确定这样的集合当前的大小通常需要遍历。
  • 有些应用使用庞大的并发散列映射,这些映射太过庞大,以至于无法用 size 方法得到它的大小,因为这个方法只能返回 int。
  • JavaSE 8 引入了一个 mappingCount 方法可以把大小作为 long 返回。

弱一致性迭代器

  • 集合返回弱一致性 (weakly consistent) 的迭代器。
  • 这意味着迭代器不一定能反映出它们被构造之后的所有的修改,但是,它们不会将同一个值返回两次,也不会拋出 ConcurrentModificationException 异常。

并发散列集

  • 并发的散列映射表,可高效地支持大量的读者和一定数量的写者。
  • 默认情况下,假定可以有多达 16 个写者线程同时执行。可以有更多的写者线程,但是,如果同一时间多于 16个,其他线程将暂时被阻塞。

可以指定更大数目的构造器,然而,恐怕没有这种必要。

14.7.2 映射条目的原子更新

举例

  • 假设我们希望统计观察到的某些特性的频度。作为一个简单的例子,假设多个线程会遇到单词,我们想统计它们的频率。
//下面的代码不是线程安全的
Long oldValue = map.get(word);
Long newValue = oldValue == null ? 1: oldValue + 1;
map.put(word, newValue); // Error-might not replace oldValue
  • 在上面的例子中,get 和 put 代码不会破坏数据结构。不过,由于操作序列不是原子的,所以结果不可预知。

解决办法

do {
	oldValue = map.get(word);
	newValue = oldValue = null ? 1 : oldValue + 1;
} while (!map.replace(word, oldValue, newValue));	
  • 传统的做法是使用 replace 操作,它会以原子方式用一个新值替换原值,前提是之前没有其他线程把原值替换为其他值。
map.putlfAbsent(word, new LongAdder());
map.get(word).increment();
//由于 putlfAbsent 返回映射的的值 (可能是原来的值,或者是新设置的值) ,所以可以组合这两个语句:
map.putlfAbsent(word, new LongAdder()).increraent():
  • 或者,可以使用一个 ConcurrentHashMap<String,AtomicLong>,或者在 Java SE 8中,还可以使用 ConcurrentHashMap<String,LongAdder>。
map.compute(word , (k, v) -> v = null ? 1: v + 1);
  • Java SE 8 中,调用 compute 方法时可以提供一个键和一个计算新值的函数。这个函数接收键和相关联的值 (如果没有值,则为 null),它会计算新值。
map.computelfAbsent(word, k -> new LongAdder())
.increment();
  • 另外还有 computelfPresent 和 computelfAbsent 方法,它们分别只在已经有原值的情况下计算新值,或者只有没有原值的情况下计算新值。
map.merge(word, 1L, (existingValue, newValue) -> existingValue + newValue);
//或者,更简单地可以写为:
map.merge(word, 1L, Long::sum);
  • 利用 merge 方法可以在首次增加一个键时通常需要做些特殊的处理。这个方法有一个参数表示键不存在时使用的初始值。否则,就会调用你提供的函数来结合原值与初始值。(与 compute 不同,这个函数不处理键)

如果传入 compute 或 merge 的函数返回 null, 将从映射中删除现有的条目。

注意函数不能太多工作

  • 使用 compute 或 merge 时,要记住你提供的函数不能做太多工作。
  • 这个函数运行时,可能会阻塞对映射的其他更新。当然,这个函数也不能更新映射的其他部分。

14.7.3 对并发散列映射的批操作

批操作

  • 批操作会遍历映射,处理遍历过程中找到的元素。无须冻结当前映射的快照。
  • 除非你恰好知道批操作运行时映射不会被修改, 否则就要把结果看作是映射状态的一个近似。

有 3 种不同的操作

  • 索 (search) 为每个键或值提供一个函数,直到函数生成一个非 null 的结果。然后搜索终止,返回这个函数的结果。
  • 归约 (reduce) 组合所有键或值,这里要使用所提供的一个累加函数。
  • forEach 为所有键或值提供一个函数。

每个操作都有 4 个版本

  • operationKeys: 处理键。
  • operatioriValues: 处理值。
  • operation: 处理键和值
  • operatioriEntries: 处理 Map.Entry 对象。

参数化阈值

  • 对于上述各个操作,需要指定一个参数化阈值 (parallelism threshold)。如果映射包含的元素多于这个阈值,就会并行完成批操作。

如果希望批操作在一个线程中运行,可以使用阈值 Long.MAX_VALUE。如果希望用尽可能多的线程运行批操作,可以使用阈值 1。

search方法

U searchKeys(long threshold, BiFunction<? super K , ? extends U> f)
U searchVaiues(long threshold, BiFunction<? super V, ? extends U> f)
U search(long threshold, BiFunction<? super K, ? super V,? extends U> f)
U searchEntries(long threshold, BiFunction<Map.Entry<K, V>, ? extends U> f)
  • 例如,假设我们希望找出第一个出现次数超过 1000 次的单词。
String result = map.search(threshold, (k, v) -> v > 1000 ? k : null);
  • result 会设置为第一个匹配的单词,如果搜索函数对所有输人都返回 null,则返回null。

forEach方法

  • forEach方法有两种形式。
  • 第一个只为各个映射条目提供一个消费者函数
map.forEach(threshold,
	(k, v) -> System.out.println(k + " -> " + v));
  • 第二种形式还有一个转换器函数,这个函数要先提供,其结果会传递到消费者:
map.forEach(threshold, 
	(k, v)-> k + " -> " + v, // Transformer
	Syste«.out::println); // Consumer
  • 转换器可以用作为一个过滤器。只要转换器返回 null , 这个值就会被悄无声息地跳过。

reduce 方法

  • reduce 操作用一个累加函数组合其输入。
//例如,可以如下计算所有值的总和:
Long sum = map.reduceValues(threshold, Long::sum);
  • 与 forEach 类似,也可以提供一个转换器函数。
//可以如下计算最长的键的长度:
Integer maxlength = map.reduceKeys(threshold,
	String::length, // Transformer
	Integer::max); // Accumulator
//在这里,我们要统计多少个条目的值 > 1000:
Long count = map. reduceValues(threshold,
	v -> v > 1000 ? 1L : null ,
	Long::sum);
  • 如果映射为空,或者所有条目都被过滤掉,reduce 操作会返回 null。
  • 如果只有一个元素,则返回其转换结果,不会应用累加器。

特殊化输出

long sum = map.reduceValuesToLong(threshold,
	Long::longValue, //Transformer to primitive type
	0, //Default value for empty map
	Long::sum); //Primitive type accumulator
  • 对于 int、long 和 double 输出还有相应的特殊化操作,分别有后缀 Tolnt、ToLong 和 ToDouble。
  • 需要把输入转换为一个基本类型值,并指定一个默认值和一个累加器函数。映射为空时返回默认值。

这些特殊化操作与对象版本的操作有所不同,对于对象版本的操作,只需要考虑一个元素。这里不是返回转换得到的元素,而是将与默认值累加。因此,默认值必须是累加器的零元素。

14.7.4 并发集视图

没有 ConcurrentHashSet 类

  • 假设你想要的是一个大的线程安全的集而不是映射,并没有一个 ConcurrentHashSet 类。
Set<String> words = ConcurrentHashMap.<String>newKeySet();
  • 静态 newKeySet 方法会生成一个 Set<K>,这实际上是 ConcurrentHashMap<K, Boolean> 的一个包装器。

所有映射值都为 Boolean.TRUE, 不过因为只是要把它用作一个集,所以并不关心具体的值。

元素处理

  • 当然,如果原来有一个映射,keySet 方法可以生成这个映射的键集。
  • 这个集是可变的。如果删除这个集的元素,这个键 (以及相应的值) 会从映射中删除。
  • 不过,不能向键集增加元素,因为没有相应的值可以增加。
  • Java SE 8 为 ConcurrentHashMap 增加了第二个 keySet 方法,包含一个默认值,可以在为集增加元素时使用:
//如果 "Java" 在 words 中不存在,现在它会有一个值1
Set<String> words = map.keySet(1L);
words.add("Java")

14.7.5 写数组的拷贝

  • CopyOnWriteArrayList 和 CopyOnWriteArraySet 是线程安全的集合,其中所有的修改线程对底层数组进行复制。
  • 如果在集合上进行迭代的线程数超过修改线程数,这样的安排是很有用的。
  • 当构建一个迭代器的时候,它包含一个对当前数组的引用。如果数组后来被修改了,迭代器仍然引用旧数组,但是,集合的数组已经被替换了。
  • 因而,旧的迭代器拥有一致的 (可能过时的) 视图,访问它无须任何同步开销。

14.7.6 并行数组算法

数组并行化操作

  • 在 Java SE 8中,Arrays 类提供了大量并行化操作。
  • 静态 Arrays.parallelSort 方法可以对一个基本类型值或对象的数组排序。
String contents = new String(Fi1es.readAl1Bytes(
	Paths.get("alice.txt")), StandardCharsets.UTF-8); // Read file into string
String[] words = contents.split("[\\P{L}]+"); // Split along nonletters
Arrays.parallelSort(words):
  • 对对象排序时,可以提供一个 Comparator。
Arrays.parallelSort(words, Comparator.comparing(String::length));
  • 对于所有方法都可以提供一个范围的边界,如:
values.parallelSort(values,length / 2, values.length); // Sort the upper half

乍一看,这些方法名中的 parallel 可能有些奇怪,因为用户不用关心排序具体怎样完成。不过,API 设计者希望清楚地指出排序是并行化的。这样一来,用户就会注意避免使用有副作用的比较器。

parallelSetAll 方法

  • parallelSetAll 方法会用由一个函数计算得到的值填充一个数组。
  • 这个函数接收元素索引,然后计算相应位置上的值。
Arrays.parallelSetAll(values,i-> i % 10);
// Fills values with 0 12 3 4 5 6 7 8 9 0 12 . . .

parallelPrefix 方法

  • 最后还有一个 parallelPrefix 方法,它会用对应一个给定结合操作的前缀的累加结果替换各个数组元素。
//考虑数组 [1,2,3,4, . . .] 和 x 操作。
Arrays.parallelPrefix(values, (x, y) -> x * y) 
//执行之后,数组将包含:
//[1,1 x 2,1 x 2 x 3, 1 x 2 x 3 x 4, . . .]

并行化问题

//可能很奇怪,不过这个计算确实可以并行化。首先,结合相邻元素,如下所示:
[1, 1 x 2, 3, 3 x 4, 5, 5 x 6, 7, 7 x 8]
//下一步中,通过将所指示的元素与下面一个或两个位置上的元素相乘来更新这些元素:
[1, 1 x 2, 1 x 2 x B, 1 x 2 x 3 x 4, 5, 5 x 6, 5 x 6 x 7, 5 x 6 x 7 x 8]
//log(») 步之后,这个过程结束。如果有足够多的处理器,这会远远胜过直接的线性计算。

14.7.7 较早的线程安全集合

遗留类

  • 从 Java 的初始版本开始,Vector 和 Hashtable 类就提供了线程安全的动态数组和散列表的实现。
  • 现在这些类被弃用了,取而代之的是 AnayList 和 HashMap 类。

同步包装器

  • 任何集合类都可以通过使用同步包装器 (synchronization wrapper) 变成线程安全的:
List<E> synchArrayList = Collections.synchronizedList(new ArrayList<E>());
Map<K, V> synchHashMap = Col1ections.synchronizedMap(new HashMap<K, V>());
  • 结果集合的方法使用锁加以保护,提供了线程安全访问。
  • 应该确保没有任何线程通过原始的非同步方法访问数据结构。

最便利的方法是确保不保存任何指向原始对象的引用, 简单地构造一个集合并立即传递给包装器,像我们的例子中所做的那样。

进行迭代

  • 如果在另一个线程可能进行修改时要对集合进行迭代,仍然需要使用“ 客户端” 锁定:
synchronized (synchHashMap) {
	Iterator<K> iter = synchHashMap.keySet().iterator();
	while (iter.hasNext()) . .
}
  • 如果使用“ foreach” 循环必须使用同样的代码,因为循环使用了迭代器。

注意:如果在迭代过程中,别的线程修改集合,迭代器会失效,抛出 ConcurrentModificationException 异常。(假设其他线程没有客户端锁定,或对原始数据修改)

使用选择

  • 最好使用 java.util.concurrent 包中定义的集合, 不使用同步包装器中的。

特别是,假如它们访问的是不同的桶,由于 ConcurrentHashMap 已经精心地实现了,多线程可以访问它而且不会彼此阻塞。

  • 有一个例外是经常被修改的数组列表。在那种情况下,同步的 ArrayList 可以胜过 CopyOnWriteArrayList()。

14.8 Callable 与 Future

Callable 接口

  • Callable 与 Runnable 类似,但是有返回值。
  • Callable 接口是一个参数化的类型,只有一个方法 call。
public interface Ca11able<V> {
	V call() throws Exception;
}

Future 接口

public interface Future<V> {
	V get() throws ...
	V get(long timeout, TimeUnit unit) throws . .
	void cancel(boolean mayInterrupt);
	boolean isCancelled();
	boolean isDone();
}
  • Future 保存异步计算的结果。
  • 可以启动一个计算,将 Future 对象交给某个线程,然后忘掉它。Future 对象的所有者在结果计算好之后就可以获得它。

方法介绍

  • 第一个 get 方法的调用被阻塞,直到计算完成。
  • 如果在计算完成之前,第二个方法的调用超时,拋出一个 TimeoutException 异常。
  • 如果运行该计算的线程被中断,两个方法都将拋出 IntermptedException。如果计算已经完成,那么 get 方法立即返回。
  • 如果计算还在进行,isDone 方法返回 false; 如果完成了,则返回 true。
  • 可以用 cancel 方法取消该计算。如果计算还没有开始,它被取消且不再开始。如果计算处于运行之中,那么如果 maylnterrupt 参数为 true,它就被中断。

FutureTask 包装器

  • FutureTask 包装器是一种非常便利的机制,可将 Callable 转换成 Future 和 Runnable,它同时实现二者的接口。
Callable<Integer> nyComputation = . . .;
FutureTask<Integer> task = new FutureTask<Integer>(myConiputation);
Thread t = new Thread(task); // it's a Runnable
t.start();
Integer result = task.get()// it's a Future

14.9 执行器

使用线程池

  • 构建一个新的线程是有一定代价的,因为涉及与操作系统的交互。

如果程序中创建了大量的生命期很短的线程,应该使用线程池 (thread pool)。

  • 另一个使用线程池的理由是减少并发线程的数目。创建大量线程会大大降低性能甚至使虚拟机崩溃。

如果有一个会创建许多线程的算法,应该使用一个线程数“ 固定的” 线程池以限制并发线程的总数。

构建线程池

  • 执行器 (Executor) 类有许多静态工厂方法用来构建线程池。
    执行者工厂方法

14.9.1 线程池

3 个方法

  • newCachedThreadPool 方法构建了一个线程池,对于每个任务,如果有空闲线程可用,立即让它执行任务,如果没有可用的空闲线程,则创建一个新线程。
  • newFixedThreadPool 方法构建一个具有固定大小的线程池。如果提交的任务数多于空闲的线程数,那么把得不到服务的任务放置到队列中。当其他任务完成以后再运行它们。
  • newSingleThreadExecutor 是一个退化了的大小为 1 的线程池:由一个线程执行提交的任务,一个接着一个。

ExecutorService

  • 这 3 个方法返回实现了 ExecutorService 接口的 ThreadPoolExecutor 类的对象。
  • 可用下面的方法之一将一个 Runnable 对象或 Callable 对象提交给 ExecutorService
Future<?> submit(Runnable task)
Future<T> submit(Runnable task, T result)
Future<T> submit(Callable<T> task)

三个submit 方法

  • 第一个 submit 方法返回一个奇怪样子的 Future<?>。可以使用这样一个对象来调用 isDone、cancel 或 isCancelled。但是,get 方法在完成的时候只是简单地返回 null。
  • 第二个版本的 Submit 也提交一个 Runnable,并且 Future 的 get 方法在完成的时候返回指定的 result 对象。
  • 第三个版本的 Submit 提交一个 Callable, 并且返回的 Future 对象将在计算结果准备好的时候得到它。

关闭线程池

  • 当用完一个线程池的时候,调用 shutdown。该方法启动该池的关闭序列。被关闭的执行器不再接受新的任务。当所有任务都完成以后,线程池中的线程死亡。
  • 另一种方法是调用 shutdownNow。该池取消尚未开始的所有任务并试图中断正在运行的线程。

连接池时应该做的事

  • 调用 Executors 类中静态的方法 newCachedThreadPool 或 newFixedThreadPool。
  • 调用 submit 提交 Runnable 或 Callable 对象。
  • 如果想要取消一个任务,或如果提交 Callable 对象, 那就要保存好返回的 Future 对象。
  • 当不再提交任何任务时,调用 shutdown。

打印出执行中池中最大的线程数

  • 不能通过 ExecutorService 这个接口得到这一信息。因此,必须将该pool 对象强制转换为 ThreadPoolExecutor 类对象。

14.9.2 预定执行

ScheduledExecutorService 接口

  • ScheduledExecutorService 接口具有为预定执行 (Scheduled Execution) 或重复执行任务而设计的方法。
  • 它是一种允许使用线程池机制的 java.util.Timer 的泛化。
  • Executors 类的newScheduledThreadPool 和 newSingleThreadScheduledExecutor 方法将返回实现了 ScheduledExecutorService 接口的对象。
  • 可以预定 Runnable 或 Callable 在初始的延迟之后只运行一次。也可以预定一个 Runnable对象周期性地运行。

14.9.3 控制任务组

shutdownNow 方法

  • 可以在执行器中使用 shutdownNow 方法取消所有的任务。

invokeAny 方法

  • invokeAny 方法提交所有对象到一个 Callable 对象的集合中,并返回某个已经完成了的任务的结果。

无法知道返回的究竟是哪个任务的结果,也许是最先完成的那个任务的结果。

invokeAll 方法

  • invokeAll 方法提交所有对象到一个 Callable 对象的集合中,并返回一个 Future 对象的列表,代表所有任务的解决方案。
List<Callab1e<T>> tasks = . . .;
List<Future<T>> results = executor.invokeAll(tasks):
for (Future<T> result : results)
	processFurther(result.get());
  • 这个方法的缺点是如果第一个任务恰巧花去了很多时间,则可能不得不进行等待。

ExecutorCompletionService

  • 将结果按可获得的顺序保存起来更有实际意义,可以用 ExecutorCompletionService 来进行排列。
  • 用常规的方法获得一个执行器。然后,构建一个 ExecutorCompletionService,提交任务给完成服务 (completion service)。
  • 该服务管理 Future 对象的阻塞队列,其中包含已经提交的任务的执行结果 (当这些结果成为可用时)。
ExecutorCompletionService<T> service = new ExecutorCompletionService<>(executor):
for (Callable<T> task : tasks) service.submit(task);
for (int i = 0; i < tasks.size();i ++)
	processFurther(servi ce.take().get())

14.9.4 Fork-Join 框架

fork-join 框架

  • Java SE 7中新引入了 fork-join 框架,专门用来支持一些可能对每个处理器内核分别使用一个线程,来完成计算密集型任务 (如图像或视频处理) 的应用。
  • 假设有一个处理任务,它可以很自然地分解为子任务,如下所示:
if (problemSize < threshold)
	//solve problem directly
else{
	//break problem into subproblems
	//recursively solve each subproblem
	//combine the results
}

采用框架

  • 要采用框架可用的一种方式完成这种递归计算,需要提供一个扩展 RecursiveTask() 的类 (如果计算会生成一个类型为 T 的结果) 或者提供一个扩展 RecursiveActicm 的类 (如果不生成任何结果)。
  • 再覆盖 compute 方法来生成并调用子任务,然后合并其结果。
class Counter extends RecursiveTask<Integer> {
	protected Integer compute() {
		if (to - fro« < THRESHOLD) {
			solve problem directly
		}else {
			int mid = (from + to) / 2;
			Counter first = new Counter(va1ues, from, mid, filter);
			Counter second = new Counter(va1ues, mid, to, filter);
			invokeAll (first, second):
			return first.join() + second.join();
		}
	}
}
  • 在这里,invokeAll 方法接收到很多任务并阻塞,直到所有这些任务都已经完成。
  • join 方法将生成结果。我们对每个子任务应用了 join,并返回其总和。
  • 还有一个 get 方法可以得到当前结果,不过一般不太使用,因为它可能抛出已检查异常,而在 compute 方法中不允许抛出这些异常。

工作密取

  • 在后台,fork-join 框架使用了一种有效的智能方法来平衡可用线程的工作负载,这种方法称为工作密取 (work stealing)。
  • 每个工作线程都有一个双端队列 (deque) 来完成任务。一个工作线程将子任务压人其双端队列的队头。

只有一个线程可以访问队头,所以不需要加锁。

  • 一个工作线程空闲时,它会从另一个双端队列的队尾“ 密取” 一个任务。由于大的子任务都在队尾,这种密取很少出现。

14.9.5 可完成 Future

问题

  • 处理非阻塞调用的传统方法是使用事件处理器,程序员为任务完成之后要出现的动作注册一个处理器。
  • 当然,如果下一个动作也是异步的,在它之后的下一个动作会在一个不同的事件处理器中。
  • 尽管程序员会认为“ 先做步骤 1 , 然后是步骤 2, 再完成步骤 3”,但实际上程序逻辑会分散到不同的处理器中。

解决办法

  • Java SE 8 的 CompletableFuture 类提供了一种候选方法。与事件处理器不同,“ 可完成 future" 可以“ 组合” (composed)。
  • 利用可完成 future,可以指定你希望做什么,以及希望以什么顺序执行这些工作。当然,这不会立即发生,不过重要的是所有代码都放在一处。

单个 future 的方法

  • 从概念上讲,CompletableFuture 是一个简单 API,不过有很多不同方法来组合可完成 future。
  • 对于这里所示的每个方法,还有两个 Async 形式,不过这里没有给出,其中一种形式使用一个共享 ForkJoinPool,另一种形式有一个 Executor 参数.
    单个future方法

这里会把 Function<? super T,U> 写为 T -> U。

thenApply 方法

CompletableFuture<U> future.thenApply(f);
CompletableFuture<U> future.thenApplyAsync(f) ;
  • 调用会返回一个 future, 可用时会对 future 的结果应用 f。第二个调用会在另一个线程中运行f 。

thenCompose 方法

  • thenCompose 方法没有取函数 T -> U, 而是取函数 T ->CompletableFuture<U>。
//考虑从一个给定 URL 读取一个 Web 页面的动作。不用提供方法:
public String b1ockingReadPage(URL url)
//更精巧的做法是让方法返回一个 fixture:
public CompletableFuture<String> readPage(URL url)
//现在,假设我们还有一个方法可以从用户输入得到 URL, 这可能从一个对话框得到,而在用户点击 OK 按钮之前不会得到答案。这也是将来的一个事件:
public CompletableFuture<URL> getURLInput(String prompt)
  • 这 里 我 们 有 两 个 函 数 T -> CompletableFuture<U> 和 U -> CompletableFuture<V>。显然, 如果第二个函数在第一个函数完成时调用,它们就可以组合为一个函数 T -> CompletableFuture<V>。这正是 thenCompose 所做的。

handle方法

  • handle方法强调了目前为止我一直忽略的另一个方面: 失败 (failure)。
  • CompletableFuture 中拋出一个异常时,会捕获这个异常并在调用 get 方法时包装在一个受查异常 ExecutionException 中。

不过,可能 get 永远也不会被调用。

  • 调用指定的函数时要提供结果 (如果没有则为 null ) 和异常 (如果没有则为null ),这种情况下就有意义了。

组合多个 future 的方法
组合多个future的方法

  • 前 3 个方法并行运行一个 CompletableFuture<T> 和一个 CompletableFuture<U> 动作,并组合结果。
  • 接下来 3 个方法并行运行两个 CompletableFuture<T> 动作。一旦其中一个动作完成,就传递它的结果,并忽略另一个结果。
  • 最 后 的 静 态 allOf 和 anyOf 方法取一组可完成 future (数目可变) ,并生成一个CompletableFuture<Void>,它会在所有这些 future 都完成时或者其中任意一个 future 完成时结束。不会传递任何结果。

CompletionStage 类 型

  • 理论上讲,这一节介绍的方法接受 CompletionStage 类型的参教,而不是 CompletableFuture。
  • 这个接口有几乎 40 个抽象方法,只由 CompletableFuture 实现。提供这个接口是为了让第三方框架可以实现这个接口。

14.10 同步器

帮助人们管理相互合作的线程集的类

  • java.util.concurrent 包包含了几个能帮助人们管理相互合作的线程集的类。
  • 这些机制具有为线程之间的共用集结点模式 (common rendezvous patterns) 提供的“ 预置功能” (canned functionality) 。
  • 如果有一个相互合作的线程集满足这些行为模式之一, 那么应该直接重用合适的库类而不要试图提供手工的锁与条件的集合。

同步器
同步器

14.10.1 信号量

信号量

  • 概念上讲,一个信号量管理许多的许可证 (permit)。为了通过信号量,线程通过调用 acquire 请求许可。
  • 其实没有实际的许可对象,信号量仅维护一个计数。许可的数目是固定的,由此限制了通过的线程数量。

其他线程可以通过调用 release 释放许可。而且,许可不是必须由获取它的线程释放。

  • 事实上,任何线程都可以释放任意数目的许可,这可能会增加许可数目以至于超出初始数目。

14.10.2 倒计时门栓

倒计时门栓

  • 一个倒计时门栓 (CountDownLatch) 让一个线程集等待直到计数变为 0。
  • 倒计时门栓是一次性的。一旦计数为 0,就不能再重用了。

一般用来初始化工作。

14.10.3 障栅

  • CyclicBarrier 类实现了一个集结点 (rendezvous) 称为障栅 (barrier)。
  • 当一个线程完成了它的那部分任务后, 我们让它运行到障栅处。一旦所有的线程都到达了这个障栅,障栅就撤销,线程就可以继续运行。

细节使用

//首先,构造一个障栅,并给出参与的线程数:
CyclicBarrier barrier = new CydicBarrier(nthreads);
//每一个线程做一些工作,完成后在障栅上调用 await
public void run() {
	doWork();
	bamer.await();
}
  • await 方法有一个可选的超时参数:
barrier.await(100, TineUnit.MILLISECONDS);

破环障栅

  • 如果任何一个在障栅上等待的线程离开了障栅,那么障栅就被破坏了 (线程可能离开是因为它调用 await 时设置了超时,或者因为它被中断了)。
  • 在这种情况下,所有其他线程的 await 方法抛出 BrokenBarrierException 异常。那些已经在等待的线程立即终止 await 的调用。

障栅动作

  • 可以提供一个可选的障栅动作 (barrier action),当所有线程到达障栅的时候就会执行这一动作。
Runnable barrierAction = . .
CyclicBarrier barrier = new Cyc1icBarrier(nthreads, barrierAction);
  • 该动作可以收集那些单个线程的运行结果。

循环使用

  • 障栅被称为是循环的 (cyclic),因为可以在所有等待线程被释放后被重用。
  • 在这一点上,有别于 CountDownLatch, CountDownLatch 只能被使用一次。

Phaser 类

  • Phaser 类增加了更大的灵活性,允许改变不同阶段中参与线程的个数。

14.10.4 交换器

交换器

  • 当两个线程在同一个数据缓冲区的两个实例上工作的时候,就可以使用交换器 (Exchanger) 。

典型的情况是,一个线程向缓冲区填人数据,另一个线程消耗这些数据。当它们都完成以后,相互交换缓冲区。

14.10.5 同步队列

同步队列

  • 同步队列是一种将生产者与消费者线程配对的机制。
  • 当一个线程调用 SynchronousQueue 的 put 方法时,它会阻塞直到另一个线程调用 take 方法为止,反之亦然。

与 Exchanger 的情况不同,数据仅仅沿一个方向传递,从生产者到消费者。

  • 即使 SynchronousQueue 类实现了 BlockingQueue 接口,概念上讲,它依然不是一个队列。它没有包含任何元素,它的 size方法总是返回 0。

14.11 线程与 Swing

Basic Spin Manual 分类: 安装配置说明 ... Basic Spin Manual Modeling LanguageControl FlowAdvanced UsageSpinSummaryAppendix:Building a Verification SuiteReferencesJapanese translation of this page Spinis a tool for analyzing t... 阅读详情

相关推荐

java核心技术卷I-线程安全(一)

线程安全的集合 如果多线程并发地修改一个数据结构, 例如散列表, 那么很容易会破坏这个数据结构 (有关散列表的详细信息见第 9 ) 。例如, 一个线程可能要开始向表中插入一个新元素。假定在调整散列表各个桶之间的链接关系的过程中, 被剥夺了控制权。如果另一个线程也开始遍历同一个链表,可能使用无效的链接并造成混乱, 会抛出异常或者陷人死循环。 可以通过提供来保护共享数据结构, 但是选择线程安全的...

weixin_42814075的博客 376

cyc并发问题总结

目录并发1、线程状态2、创建线程的几种方法Callable创建线程(传给FutureTask,FutureTask再传给Thread)3、Executor框架守护线程(Daemon)yield()4、中断interrupt(设置标志位,设完interrupted()返回true)Executor 的中断操作5、互斥同步同步同一个对象同步一个类6、synchronized和ReentrantLo...

weixin_38367817的博客 319

资源try语句

最近,在学习RabbitMQ时,根据官方文档进行Connection连接时,官方文档提供了示例代码,如下: try (Connection connection = factory.newConnection(); Channel channel = connection.createChannel()) { ... } 在try语句后面,有一对括弧(),里面包含了两行代码...

高敬超的博客 923

java 资源try语句

jdk 1.7之后出现的一个新的语句,主要用于关闭资源。        在原来关闭资源的时候,用 try-catch-finally 时如果try中的代码跑出了一个非 IOException,在执行finally调用close方法时close方法本身也会有可能抛出 IOException 异常。这种情况下,原始的异常将丢失,转而抛出close方法的异常。        在jkd 1.7之前的处理方法

My Blog 3876

14 并发编程

当前自编代码有时受网速限制,尚未受系统资源限制,暂时用不上并发编程 概述 同一CPU在某时间点只能执行一个程序,由于速度快,容易产生多个程序同时执行的错觉 并发 Concurrency 多进程快速轮换 并行 Parallel 同一时刻,多指令在多个处理器上同时执行 进程的特征 独立性。有独立的地址空间 动态性。是活动中的指令集合(程序是静态的指令集合),有自己的生命周期、状态 并发性。多个进程可以在同一处理器上并发执行,互不影响 线程的特征 同一进程下可以包含多个线程,各线程共享该

Ailsa2019的博客 455

Oracle 第14并发控制

在Oracle数据库中,并发控制是一个关键概念,因为它确保了多个用户或事务可以同时访问数据库而不干扰彼此的工作。并发问题主要出现在多用户环境中,当多个事务试图同时修改相同的数据时可能发生数据不一致的问题。

hummhumm的专栏 1245

Java核心技术》第14 并发

14 并发 一个程序同时执行多个任务。通常,每一个任务称为一个线程(thread),它是线程控制的简称。可以同时运行一个以上线程的程序称为多线程程序(multithreaded)。 那么,多进程与多线程有哪些区别呢?本质的区别在于每个进程拥有自己的一整套变量,而线程则共享数据。 共享变量使线程之间的通信比进程之间的通信更有效、更容易。此外,在有些操作系统中,与进程相比较,线程更“轻量级”,创建、撤销一个线程比启动新进程的开销要小得多。 14.1 什么是线程 调用Thread.sleep不会创建一个新线程

兰陵鸡王的博客 126

14 并发控制与恢复

事务根据需要获取。一旦事务释放,它就会进入收缩阶段,并且无法发出定请求)两段协议是指同一事务对任何数据进行读写之前必须对该数据加,在释放一个封之后,该事务不再申请和获得任何其他封。立即修改方案允许在事务提交之前对缓冲区或磁盘本身更新未提交的事务,更新的块输出到磁盘可以在。考试范围: 14.1-14.3, 14.8-14.11。的任何时间进行,块的输出顺序可能与写入块的。考试题型: 事务操作。T1符合两段协议,两阶段定协议的变体。

Flipped7777的博客 763

Java核心技术 卷一 第14 并发

目录 什么是线程 使用线程给其他任务提供机会 中断线程 线程状态 新创建线程 可运行线程 被阻塞线程和等待线程 被终止的线程 线程属性 线程优先级 守护线程 未捕获异常处理器 同步 竞争条件的一个例子 竞争条件详解 对象 条件对象 synchronize关键字 同步阻塞 监视器概念 Volatile域 final变量 原子性 死 线程局部...

诗人密语 1431

14 :从开源框架看并发设计

前面的节帮助我们建立了 Go 并发编程的工具箱和知识体系。但工程能力的提升,离不开对真实系统的分析和借鉴。优秀的开源框架经过了大量生产环境的检验,其中的并发设计往往是权衡取舍后的最佳实践。本通过分析三个真实案例——Go 官方博客的 URL Poller(通过通信共享内存的经典示范)、beego 框架的并发启动流程,以及标准库。14.2 实战:URL Poller——通过通信共享内存。的并发设计——帮助读者将前面所学融会贯通。第 14 :从开源框架看并发设计。

技术原始积累 26

14 事务管理与并发控制机制

本文摘要: 第14介绍了数据库事务管理机制,重点讲解了事务的ACID特性(原子性、一致性、隔离性和持久性)。通过SQL Server示例演示了事务的启动(BEGIN TRAN)、提交(COMMIT TRAN)和回滚(ROLLBACK TRAN)操作,包括账户转账等典型应用场景。文还展示了如何使用事务保存点(SAVE TRAN)实现部分回滚,以及通过TRY-CATCH结构进行错误处理,确保数据库操作的完整性和一致性。示例代码包含创建银行账户表、执行转账交易以及记录交易日志等完整实现。

小宝哥Code的专栏 894

Java并发编程实战》第14学习笔记

构建自定义的同步工具 在自定义的同步工具类中,要避免使用轮询和休眠实现方法的阻塞。 使用轮询等待方法操作并重试时,这种方法称为忙等待或自旋等待,此时会消耗大量的CPU时间; // 如果缓存在很长的一段时间里都为空且一直不发生变化,这种机制会造成大量的cpu时钟周期的浪费。 public V take(){ while(true){ try{ if(!buffer.isEmpty()){ V item = buffer.take

weixin_45207474的博客 200

14 线程机制:并发函数的使用

1.一个简单的线程 /* hello_multi.c - a multi-threaded hello world program */ #include<stdio.h> #include<pthread.h> #define NUM 5 void *print_msg(void *); void main() { pthread_t t1, t2; /* t...

coffee0218的博客 915

第5并发与竞态条件-14:Alternatives to Locking

本文探讨Linux内核中无编程的替代方案。首先指出机制虽常用但存在陷阱,随后重点分析环形缓冲区这一无数据结构的实现原理:单生产者/消费者场景下,通过分离读写索引和严格的状态判断规则(空状态为读写索引相等,满状态为写索引紧邻读索引),可避免竞争条件。文还提及2.6.10+内核提供的通用环形缓冲区接口,并强调无算法仅适用于简单并发场景,复杂情况仍需依赖机制或原子操作。最后补充说明环形缓冲区在网络驱动中的典型应用及实现注意事项。

图形/计算GPU的驱动与虚拟化技术分享园地 1933

java核心技术 基础知识<14 并发 part2>

目录java核心技术 基础知识<14 part2>14 part214.7 线程安全的集合14.7.2 map条目的原子更新14.7.3 并发散列的批操作14.7.4 并发Set视图14.7.5 写数组的拷贝14.7.6 并行数组算法14.7.7 较早的线程安全集合 java核心技术 基础知识<14 part2> 14 part2 14.7 线程安全的集合 包前缀:java.util.concurrent.XXX ConcurrentLinkedQueue<E> // 无边界非阻塞的

Dus的博客 435

Java核心技术卷I-14并发

14.1 什么是线程 进程与线程 进程是资源分配的基本单位;线程是任务调度的基本单位; 一个进程可以包含一个或多个线程,线程可以理解为是轻量级的进程; 进程拥有自己一整套变量,而线程则共享数据,共享变量使得线程之间的通信变得更有效容易; 创建线程的两种方式 (1)实现Runnable接口(推荐使用,将线程和执行体解耦) //实现接口 Runnable r = ()-> {task cod...

Kaiyang_Shao的博客 308

[Java并发编程实战] 第14 构建自定义的同步工具

状态依赖性的管理 构成前提条件的状态变量必须由对象的来保护,从而使他们在测试前提条件的同时保持不变;如果前提条件尚未满足,就必须释放,以便其它线程可以修改对象的状态,否则,前提条件就永远无法变成真。在再次测试前提条件之前,必须重新获得 将前提条件的失败传递给调用者,调用者可以选择休眠等待、自旋等待或者调用Thread.yield 可以通过简单的“轮询与休眠”重试机制实现阻塞,同时...

Blanchedingding的博客 327
上一篇: openwrt 环境搭建(win10子系统)
下一篇: 呕心搬运!Java 核心技术卷一知识点最全总结贴
捡一晌贪欢
博客等级 码龄9年 70粉丝 156原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值