学习笔记:Java 并发编程④_无锁
若文章内容或图片失效,请留言反馈。
部分素材来自网络,若不小心影响到您的利益,请联系博主删除。
- 视频链接:https://www.bilibili.com/video/av81461839
- 配套资料:https://pan.baidu.com/s/1lSDty6-hzCWTXFYuqThRPw( 提取码:5xiu)
写这篇博客旨在制作笔记,方便个人在线阅览,巩固知识。无他用。
博客的内容主要来自视频内容和资料中提供的学习笔记。当然,在此基础之上也增删了一些内容。
参考书籍:《实战 JAVA 高并发程序设计》 葛一鸣 著
系列目录
- 学习笔记:Java 并发编程①_基础知识入门
- 学习笔记:Java 并发编程②_共享模型之管程
- 学习笔记:Java 并发编程③_共享模型之内存
- 学习笔记:Java 并发编程④_共享模型之无锁
- 学习笔记:Java 并发编程⑤_共享模型之不可变
- 学习笔记:Java 并发编程⑥_共享模型之并发工具_线程池
- 学习笔记:Java 并发编程⑥_共享模型之并发工具_JUC
- 学习笔记:Java 并发编程(补)CompletableFuture
- 学习笔记:Java 并发编程(补)ThreadLocal
本章内容:CAS 与 volatile、原子整数、原子引用、原子累加器、Unsafe
1.初步体验
1.1.提出问题
需求:保证 account.withdraw 取款方法的线程安全
Account.java
public interface Account {// 获取余额Integer getBalance();// 取款void withdraw(Integer amount);/*** 方法内会启动 1000 个线程,每个线程做 -10 元的操作* 如果初始余额是 10000,那么正确的结果应该是 0*/static void demo(Account account) {List<Thread> threads = new ArrayList<>();for (int i = 0; i < 1000; i++) {threads.add(new Thread(() -> {account.withdraw(10);}));}long startTime = System.nanoTime();threads.forEach(Thread::start);threads.forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});long endTime = System.nanoTime();System.out.println(account.getBalance() + " cost:" + (endTime - startTime) / 1000_000 + "ms");}
}
AccountUnsafe.java
public class AccountUnsafe implements Account {private Integer balance;public AccountUnsafe(Integer balance) {this.balance = balance;}@Overridepublic Integer getBalance() {return this.balance;}@Overridepublic void withdraw(Integer amount) {this.balance -= amount;}
}
测试类:TestAccount.java
public class TestAccount {public static void main(String[] args) {Account accountUnsafe = new AccountUnsafe(10000);Account.demo(accountUnsafe);}
}
上面的代码肯定是达不到需求的。
因为余额(balance)是共享资源,多个线程对会对它就行读写操作,withdraw() 很明显是临界区。
控制台的输出结果也不是每次都是 0
320 cost:192ms
1.2.加锁实现
我们可以加锁来实现它的线程安全以达到需求。(最好是读写都加锁)
AccountUnsafe.java
0 cost:191ms
1.3.无锁实现
参考书籍:《实战 JAVA 高并发程序设计》 葛一鸣 著
对于并发控制而言,锁是一种悲观的策略。它总是假设每一次的临界区操作会产生冲突。因此,必须对每次操作都小心翼翼。如果有多个线程同时需要访问临界区资源,就宁可牺牲性能让线程进行等待,所以说锁会阻塞线程执行。
对于并发控制而言,无锁是一种乐观的策略,它会假设访问是没有冲突的。既然没有冲突,自然不需要等待,所以所有的线程都可以在不停顿的状态下持续执行。那遇到冲突怎么办呢?无锁的策略使用一种叫做比较交换的技术(CAS,即 CompareAnd Swap)来鉴别线程冲突,一旦检测到冲突产生,就重试当前操作直到没有冲突为止。
与锁相比,使用比较交换(下文简称 CAS)会使程序看起来更加复杂一些。但由于其非阻塞性,它对死锁问题天生免疫,并且,线程间的相互影响也远远比基于锁的方式要小。更为重要的是,使用无锁的方式完全没有锁竞争带来的系统开销,也没有线程间频繁调度带来的开销,因此,它要比基于锁的方式拥有更优越的性能。
在硬件层面,大部分的现代处理器都已经支持原子化的 CAS 指令。在 JDK 5.0 以后,虚拟机便可以使用这个指令来实现并发操作和并发数据结构,并且,这种操作在虚拟机中可以说是无处不在。为了让 Java 程序员能够受益于 CAS 等 CPU 指令,JDK 并发包中有一个 atomic 包,里面实现了一些直接使用 CAS 操作的线程安全的类型。其中,最常用的一个类,应该就是 AtomicInteger。你可以把它看做是一个整数。但是与 Integer 不同,它是可变的,并且是线程安全的。对其进行修改等任何操作,都是用 CAS 指令进行的。
接下来,我们需要对上面的代码块做一些改动,通过无锁的方式来达到需求。
其中 Account.java
代码是无需改动的。
AccountCAS.java
public class AccountCAS implements Account_1 {private AtomicInteger balance;public AccountCAS(int balance) {this.balance = new AtomicInteger(balance);}@Overridepublic Integer getBalance() {return balance.get();}@Overridepublic void withdraw(Integer amount) {while (true) {// 获取余额的最新值int prev = balance.get();// 要修改的余额int next = prev - amount;// 真正意义上的修改if (balance.compareAndSet(prev, next)) {break;}}}
}
测试代码:TestAccount.java
public class TestAccount {public static void main(String[] args) {Account_1 accountUnsafe = new AccountUnsafe(10000);Account_1.demo(accountUnsafe);Account_1 accountCAS = new AccountCAS(10000);Account_1.demo(accountCAS);}
}
输出结果
0 cost:76 ms
0 cost:69 ms
2.CAS
2.1.CAS 的工作方式
相关视频:CAS 的工作方式
前面看到的 AtomicInteger 的解决方法,内部并没有用锁来保护共享变量的线程安全。那么它是如何实现的呢?
public void withdraw(Integer amount) {while (true) {// 需要不断尝试,直到成功为止while (true) {// 比如拿到了旧值 1000int prev = balance.get();// 在这个基础上 1000-10 = 990int next = prev - amount;/* * CompareAndSet 正是做这个检查,在 set 前,先比较 prev 与当前值** [不一致] next 作废,返回 false 表示失败* * 比如,别的线程已经做了减法,当前值已经被减成了 990* * 那么本线程的这次 990 就作废了,进入 while 下次循环重试** [一致] 以 next 设置为新值,返回 true 表示成功*/}if (balance.compareAndSet(prev, next)) {break;}}
}
其中的关键是 compareAndSet,它的简称就是 CAS(也有 Compare And Swap 的说法),它必须是原子操作。
其实 CAS 的底层是 lock cmpxchg
指令(X86 架构),在单核 CPU 和多核 CPU 下都能够保证 比较-交换 的原子性。
在多核状态下,某个核执行到带 lock
的指令时,CPU 会让总线锁住,当这个核把此指令执行完毕,再开启总线。这个过程中不会被线程的调度机制所打断,保证了多个线程对内存操作的准确性,是原子的。
不过相较于视频,我个人觉得书中的分析更加容易理解(其实和上面那张图也差不了多少)
参考书籍:《实战 JAVA 高并发程序设计》 葛一鸣 著
CAS 算法的过程是这样:它包含三个参数
CAS(V, E, N)
。V 表示要更新的变量,E 表示预期值,N 表示新值。仅当 V 值等于 E 值时,才会将 V 的值设为 N,如果 V 值和 E 值不同,则说明已经有其他线程做了更新,则当前线程什么都不做。最后,CAS 返回当前 V 的真实值。CAS 操作是抱着乐观的态度进行的,它总是认为自己可以成功完成操作。当多个线程同时使用 CAS 操作一个变量时,只有一个会胜出,并成功更新,其余均会失败。失败的线程不会被挂起,仅是被告知失败,并且允许再次尝试,当然也允许失败的线程放弃操作。基于这样的原理,CAS 操作即使没有锁,也可以发现其他线程对当前线程的干扰,并进行恰当的处理。简单地说,CAS 需要你额外给出一个期望值,也就是你认为这个变量现在应该是什么样子的。如果变量不是你想象的那样,那说明它已经被别人修改过了。你就重新读取,再次尝试修改就好了。
2.2.CAS 慢动作分析
这里基本就是用代码来演示上面的分析过程(如果理解了上面的分析的话,这个 2.2 小节的代码块就不用看了)
相关视频:CAS 慢动作分析
SlowMotion.java
@Slf4j
public class SlowMotion {public static void main(String[] args) {AtomicInteger balance = new AtomicInteger(10000);int mainPrev = balance.get();log.debug("try get {}", mainPrev);new Thread(() -> {sleep(1000);int prev = balance.get();balance.compareAndSet(prev, 9000);log.debug(balance.toString());}, "t1").start();sleep(2000);log.debug("try set 8000...");boolean isSuccess = balance.compareAndSet(mainPrev, 8000);log.debug("is success ? {}", isSuccess);if (!isSuccess) {mainPrev = balance.get();log.debug("try set 8000...");isSuccess = balance.compareAndSet(mainPrev, 8000);log.debug("is success ? {}", isSuccess);}}private static void sleep(int millis) {try {Thread.sleep(millis);} catch (InterruptedException e) {e.printStackTrace();}}
}
控制台输出
17:13:36.207 [main] DEBUG org.example.chapter06.SlowMotion - try get 10000
17:13:37.264 [t1] DEBUG org.example.chapter06.SlowMotion - 9000
17:13:38.263 [main] DEBUG org.example.chapter06.SlowMotion - try set 8000...
17:13:38.263 [main] DEBUG org.example.chapter06.SlowMotion - is success ? false
17:13:38.263 [main] DEBUG org.example.chapter06.SlowMotion - try set 8000...
17:13:38.263 [main] DEBUG org.example.chapter06.SlowMotion - is success ? true
2.3.CAS 与 volatile
获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取 它的值,线程操作 volatile 变量都是直接操作主存。即一个线程对 volatile 变量的修改,对另一个线程可见。
注意事项:volatile 仅仅保证了共享变量的可见性,让其它线程能够看到最新值,但不能解决指令交错问题(不能保证原子性)
CAS 必须借助 volatile 才能读取到共享变量的最新值来实现 比较并交换 的效果
例:AtomicInteger 中的两个字段(java/util/concurrent/atomic/AtomicInteger.java
)
// AtomicInteger 当前的实际取值private volatile int value;
// 保存着 value 字段在 AtomicInteger 对象中的偏移量private static final long valueOffset;
2.4.为什么无锁效率高
- 无锁情况下,即使重试失败,线程始终在高速运行,没有停歇。
而 synchronized 会让线程在没有获得锁的时候,发生上下文切换,进入阻塞。 - 打个比喻:线程就好像高速跑道上的赛车,高速运行时,速度超快,一旦发生上下文切换,就好比赛车要减速、熄火,等到它被唤醒时又得重新打火、启动、加速… 恢复到高速运行,这样做的代价是比较大的
- 但是在无锁情况下,因为线程要保持运行,需要额外 CPU 的支持,CPU 在这里就好比高速跑道,没有额外的跑道,线程想高速运行也无从谈起,虽然不会进入阻塞,但由于没有分到时间片,仍然会进入可运行状态,还是会导致上下文切换。当线程数小于等于 CPU 核心数时,使用无锁方案是比较划算的,是有足够多的 CPU 让线程运行的;当线程数远多于 CPU 核心数时,无锁的效率相比于有锁的效率就没有太大的优势了,此时依旧会发生上下文切换。
2.5.CAS 特点总结
结合 CAS 和 volatile 可以实现无锁并发,适用于线程数少、多核 CPU 的场景下。
- CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试。
- synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
- CAS 体现的是无锁并发、无阻塞并发。请仔细体会这句话的意思:
- 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一。
- 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响。
3.CAS 相关工具类
3.1.原子整数
J.U.C
并发包提供了:AtomicBoolean、AtomicInteger、AtomicLong
此处介绍一下 AtomicInteger,相较于其他的原子类,操作都是类似的。
3.1.1.基本方法
下方的代码块是 AtomicInteger 的一些主要方法
java/util/concurrent/atomic/AtomicInteger.java
// 取得当前值
public final int get() { return value; } // 设置当前值
public final void set(int newValue) { value = newValue; } // 设置新值,并返回旧值
public final int getAndSet(int newValue) { return unsafe.getAndSetInt(this, valueOffset, newValue);
} // 若当前值为 except,则设置为 update
public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}// 当前值加 1,返回旧值
public final int getAndIncrement() { return unsafe.getAndAddInt(this, valueOffset, 1);
}// 当前值减 1,返回旧值
public final int getAndDecrement() { return unsafe.getAndAddInt(this, valueOffset, -1);
}// 当前值增加 delta,返回旧值
public final int getAndAdd(int delta) {return unsafe.getAndAddInt(this, valueOffset, delta);
}// 当前值加 1,返回新值
public final int incrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}// 当前值减 1,返回新值
public final int decrementAndGet() {return unsafe.getAndAddInt(this, valueOffset, -1) - 1;
}// 当前值增加 delta,返回新值
public final int addAndGet(int delta) {return unsafe.getAndAddInt(this, valueOffset, delta) + delta;
}
下方的代码块是 AtomicInteger 的两个重要字段
java/util/concurrent/atomic/AtomicInteger.java
// 该字段是 AtomicInteger 当前的实际取值
private volatile int value;// 该字段保存着 value 字段在 AtomicInteger 对象中的偏移量
private static final long valueOffset;
测试代码-1
public class TestAtomicInteger_1 {public static void main(String[] args) {AtomicInteger i = new AtomicInteger(0);System.out.println("i.get():" + i.get());System.out.println("i.incrementAndGet():" + i.incrementAndGet()); // ++iSystem.out.println("i.getAndIncrement():" + i.getAndIncrement()); // i++System.out.println("i.get():" + i.get());System.out.println("i.addAndGet(5):" + i.addAndGet(5));System.out.println("i.getAndAdd(5):" + i.getAndAdd(5));System.out.println("i.get():" + i.get());}
}
输出结果-1
i.get():0
i.incrementAndGet():1
i.getAndIncrement():1
i.get():2
i.addAndGet(5):7
i.getAndAdd(5):7
i.get():12
3.1.2.复杂运算方法
此外,再介绍一个 AtomicInteger 中的方法:updateAndGet。该方法可以做一些复杂运算。
java/util/concurrent/atomic/AtomicInteger.java
public final int updateAndGet(IntUnaryOperator updateFunction) {int prev, next;do {prev = get();next = updateFunction.applyAsInt(prev);} while (!compareAndSet(prev, next));return next;
}
上述代码块中的方法中的 IntUnaryOperator 是一个函数式接口,而且其内部只包含了一个抽象方法。这就意味着该接口可以配合 Lambda 表达式来使用。在这个 Lambda 表达式中,参数代表的是我们要读取到的值,运算的结果就是将来我们要设置的值。
// 所谓的函数式接口,其实就是在这个接口里面,只能有一个抽象方法。@FunctionalInterface // 该注解只能标记在 "有且仅有一个抽象方法" 的接口上,主要用于编译期的错误检查
public interface IntUnaryOperator {int applyAsInt(int operand);... ...
}
测试代码-2
public class TestAtomicInteger_2 {public static void main(String[] args) {AtomicInteger i = new AtomicInteger(1);int value_1 = i.updateAndGet(x -> x * 10);System.out.println("i.getAndUpdate(x -> x * 10):" + value_1);// 10System.out.println("i.get():" + i.get()); // 10int value_2 = i.getAndUpdate(y -> y * 10);System.out.println("i.getAndUpdate(y -> y * 10):" + value_2);// 10System.out.println("i.get():" + i.get()); // 100// updateAndGet(IntUnaryOperator updateFunction):更新后返回新值// getAndUpdate(IntUnaryOperator updateFunction):更新后返回旧值}
}
输出结果-2
i.getAndUpdate(x -> x * 10):10
i.get():10
i.getAndUpdate(y -> y * 10):10
i.get():100
在 updateAndGet 方法中,用函数式接口来作为参数,其目的是:一个方法,多种运算实现。
为了追求代码的通用性,要把计算的操作当成一个变化的参数传递进来(比如加减乘除),这里就用到了接口的思想。
IntUnaryOperator 中的 applyAsInt(int operand)
方法就是接受一个整数,返回一个整数。至于它中间做了什么操作,我们并不关心。我们只要给一个旧值,updateAndGet 方法给我们一个计算结果,之后我们再拿这两个值做 CAS 操作、做原子更新操作即可。
这里我们可以自己实现一下 java/util/concurrent/atomic/AtomicInteger.java
中 updateAndGet 方法的逻辑
public class TestAtomicInteger_3 {public static void main(String[] args) {AtomicInteger i = new AtomicInteger(10);updateAndGet(i, oldValue -> oldValue / 2);System.out.println(i.get()); // 输出结果是 5}public static int updateAndGet(AtomicInteger i, IntUnaryOperator operator) {while (true) {int prev = i.get();int next = operator.applyAsInt(prev);if (i.compareAndSet(prev, next)) {return next;}}}
}
上方的代码块和源码中的方法实现,并没有什么太大的区别。诸位可以自行对比一下。
3.2.原子引用
在实际开发中,我们要保护的共享数据不一定是基本数据类型,也可能是 decimal 这种小数类型,此时我们就可以使用原子引用保证操作共享变量时的线程安全。原子引用类型有以下几种:AtomicReference、AtomicMarkableReference、AtomicStampedReference。
3.2.1.AtomicReference
其实这个 AtomicReference 和上面介绍的 AtomicInteger 也并无太大的区别
下面的代码块就是对之前的案例的一个改造。
DecimalAccount.java
public interface DecimalAccount {BigDecimal getBalance(); // 获取余额void withdraw(BigDecimal amount); // 取款static void demo(DecimalAccount decimalAccount) {List<Thread> threads = new ArrayList<>();for (int i = 0; i < 1000; i++) {threads.add(new Thread(() -> {decimalAccount.withdraw(BigDecimal.TEN);}));}threads.forEach(Thread::start);threads.forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});System.out.println(decimalAccount.getBalance());}}
DecimalAccountCAS.java
public class DecimalAccountCAS implements DecimalAccount {private AtomicReference<BigDecimal> balance;public DecimalAccountCAS(BigDecimal balance) {this.balance = new AtomicReference<>(balance);}@Overridepublic BigDecimal getBalance() {return balance.get();}@Overridepublic void withdraw(BigDecimal amount) {while (true) {BigDecimal prev = balance.get();BigDecimal next = prev.subtract(amount);if (balance.compareAndSet(prev, next)) {break;}}}
}
TestDecimalAccount.java
public class TestDecimalAccount {public static void main(String[] args) {DecimalAccount.demo(new DecimalAccountCAS(new BigDecimal("10000")));}
}
输出结果是 0,符合预期。
3.2.2.ABA 问题
先让我们来看看下方的代码
TestAtmoicReference.java
@Slf4j(topic = "c.TestAtomicReference")
public class TestAtmoicReference {static AtomicReference<String> ref = new AtomicReference<>("A");public static void main(String[] args) throws InterruptedException {log.debug("Main start ...");// 获取值String prev = ref.get();other();sleep(1);// 尝试改为 Clog.debug("change A->C:{}", ref.compareAndSet(prev, "C"));}private static void other() {new Thread(() -> {log.debug("change A->B:{}", ref.compareAndSet(ref.get(), "B"));}, "t1").start();sleep(0.5);new Thread(() -> {log.debug("change B->A:{}", ref.compareAndSet(ref.get(), "A"));}, "t2").start();}
}
控制台输出
22:43:22.354 [main] DEBUG c.TestAtomicReference - Main start ...
22:43:22.400 [t1] DEBUG c.TestAtomicReference - change A->B:true
22:43:22.909 [t2] DEBUG c.TestAtomicReference - change B->A:true
22:43:23.917 [main] DEBUG c.TestAtomicReference - change A->C:true
显然,当前线程无法正确判断 AtomicReference 类型的对象是否被其他线程修改过。
主线程仅能判断出共享变量的值与最初值 A 是否相同,不能感知到这种从 A 改为 B 又 改回 A 的情况。
如果主线程希望:只要有其它线程 动过了 共享变量,那么自己的 CAS 就算失败,这时,仅比较值是不够的,需要再加一个版本号。
参考书籍:《实战 JAVA 高并发程序设计》 葛一鸣 著
AtomicReference 和 AtomicInteger 非常类似,不同之处就在于 AtomicInteger 是对整数的封装,而 AtomicReference 则对应普通的对象引用。也就是它可以保证你在修改对象引用时的线程安全性。
在介绍 AtomicReference 的同时,我希望同时提出一个有关原子操作的逻辑上的不足。
之前我们说过,线程判断被修改对象是否可以正确写入的条件是对象的当前值和期望值是否一致。这个逻辑从一般意义上来说是正确的。但有可能出现一个小小的例外,就是当你获得对象当前数据后,在准备修改为新值前,对象的值被其他线程连续修改了两次,而经过这两次修改后,对象的值又恢复为旧值。这样,当前线程就无法正确判断这个对象究竟是否被修改过。
打一个比方,如果有一家蛋糕店,为了挽留客户,决定为贵宾卡里余额小于 20 元的客户一次性赠送 20 元,刺激消费者充值和消费。但条件是,每一位客户只能被赠送一次。此时,如果用户正好正在进行消费,就在赠予金额到账的同时,他进行了一次消费,使得总金额又小于 20 元,并且正好累计消费了 20 元。使得消费、赠予后的金额等于消费前、赠予前的金额。这时,后台的赠予进程就会误以为这个账户还没有赠予,所以,存在被多次赠予的可能。从这一段输出中,可以看到,这个账户被先后反复多次充值。其原因正是因为账户余额被反复修改,修改后的值等于原有的数值,使得 CAS 操作无法正确判断当前数据状态。
3.2.3.AtomicStampedReference
参考书籍:《实战 JAVA 高并发程序设计》 葛一鸣 著
AtomicReference 无法解决上述问题的根本因为是对象在修改过程中,丢失了状态信息。对象值本身与状态被画上了等号。因此,我们只要能够记录对象在修改过程中的状态值,就可以很好地解决对象被反复修改导致线程无法正确判断对象状态的问题。
AtomicStampedReference 正是这么做的。它内部不仅维护了对象值,还维护了一个版本号(它可以使任何一个整数来表示状态值)。
当 AtomicStampedReference 对应的数值被修改时,除了更新数据本身外,还必须要更新时版本号。当 AtomicStampedReference 设置对象值时,对象值以及时间戳都必须满足期望值,写入才会成功。因此,即使对象值被反复读写,写回原值,只要版本号发生变化,就能防止不恰当的写入。
AtomicStampedReference 可以给原子引用加上版本号,追踪原子引用整个的变化过程,如:A -> B -> A -> C
。
通过 AtomicStampedReference,我们可以知道,引用变量中途被更改了几次。
TestAtmoicStampedReference.java
@Slf4j(topic = "c.TestAtomicStampedReference")
public class TestAtmoicStampedReference {static AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);public static void main(String[] args) throws InterruptedException {log.debug("Main start ...");// 获取值String prev = ref.getReference();// 获取版本号int stamp = ref.getStamp();log.debug("版本号:{}", stamp);other();sleep(1);// 尝试改为 Clog.debug("版本号:{}", stamp);log.debug("change A->C:{}", ref.compareAndSet(prev, "C", stamp, stamp + 1));}private static void other() {new Thread(() -> {int stamp = ref.getStamp();log.debug("版本号:{}", stamp);log.debug("change A->B:{}", ref.compareAndSet(ref.getReference(), "B", stamp, stamp + 1));}, "t1").start();sleep(0.5);new Thread(() -> {int stamp = ref.getStamp();log.debug("版本号:{}", stamp);log.debug("change B->A:{}", ref.compareAndSet(ref.getReference(), "A", stamp, stamp + 1));}, "t2").start();}
}
输出结果
22:45:44.452 [main] DEBUG c.TestAtomicStampedReference - Main start ...
22:45:44.464 [main] DEBUG c.TestAtomicStampedReference - 版本号:0
22:45:44.508 [t1] DEBUG c.TestAtomicStampedReference - 版本号:0
22:45:44.508 [t1] DEBUG c.TestAtomicStampedReference - change A->B:true
22:45:45.015 [t2] DEBUG c.TestAtomicStampedReference - 版本号:1
22:45:45.015 [t2] DEBUG c.TestAtomicStampedReference - change B->A:true
22:45:46.018 [main] DEBUG c.TestAtomicStampedReference - 版本号:0
22:45:46.018 [main] DEBUG c.TestAtomicStampedReference - change A->C:false
列举一下 AtomicStampedReference 的几个 API
// 比较设置。参数依次为:期望值、写入新值、期望版本号、新版本号
public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) // 获得当前对象引用
public V getReference() // 获得当前版本号
public int getStamp()// 设置当前对象引用和版本号
public void set(V newReference, int newStamp)
3.2.4.AtomicMarkableReference
但是有时候,并不关心引用变量更改了几次,只是单纯的关心是否更改过,所以就有了 AtomicMarkableReference
GarbageBag.java
public class GarbageBag {String desc;public GarbageBag(String desc) { this.desc = desc; }public void setDesc(String desc) { this.desc = desc; }@Overridepublic String toString() {return super.toString() + " " + desc;}
}
TestAtomicMarkableReference.java
@Slf4j(topic = "c.TestAtomicMarkableReference")
public class TestAtomicMarkableReference {public static void main(String[] args) throws InterruptedException {GarbageBag bag = new GarbageBag("装满了垃圾");// 参数 2(mark)可以看作是一个标记,表示垃圾袋满了AtomicMarkableReference<GarbageBag> ref = new AtomicMarkableReference<>(bag, true);log.debug("主线程 start...");GarbageBag prev = ref.getReference();log.debug(prev.toString());new Thread(() -> {log.debug("打扫卫生的线程 start...");bag.setDesc("空垃圾袋");while (!ref.compareAndSet(bag, bag, true, false)) { }log.debug(bag.toString());}).start();Thread.sleep(1000);log.debug("主线程想换一只新垃圾袋?");boolean success = ref.compareAndSet(prev, new GarbageBag("空垃圾袋"), true, false);log.debug("换了么?" + success);log.debug(ref.getReference().toString());}
}
控制台输出
23:23:55.349 [main] DEBUG c.TestAtomicMarkableReference - 主线程 start...
23:23:55.360 [main] DEBUG c.TestAtomicMarkableReference - org.example.chapter06.GarbageBag@446cdf90 装满了垃圾
23:23:55.405 [Thread-0] DEBUG c.TestAtomicMarkableReference - 打扫卫生的线程 start...
23:23:55.405 [Thread-0] DEBUG c.TestAtomicMarkableReference - org.example.chapter06.GarbageBag@446cdf90 空垃圾袋
23:23:56.414 [main] DEBUG c.TestAtomicMarkableReference - 主线程想换一只新垃圾袋?
23:23:56.414 [main] DEBUG c.TestAtomicMarkableReference - 换了么?false
23:23:56.414 [main] DEBUG c.TestAtomicMarkableReference - org.example.chapter06.GarbageBag@446cdf90 空垃圾袋
3.3.原子数组
有时候我们想修改的不是引用本身,而是要修改引用对象里面的内容,典型的例子就是数组。
原子数组:AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
下面的代码块涉及到了函数式接口,所以我们先于此回顾一下函数式接口的知识。
- 函数式接口的定义:有且仅有一个抽象方法的接口。
@FunctionalInterface
:用于编译期的错误检查。该注解只能标记在 “有且仅有一个抽象方法” 的接口上。
Java 8 在 java.util.function
包下预定义了大量的函数式接口供我们使用
这里简单介绍一下其中的三个:Supplier、Consumer、Function
- Supplier 提供者。无中生有:
()->结果
- Function 函数,一个参数一个结果:
(参数)->结果
如果是两个参数一个结果的话,就是 BiFunction:(参数1, 参数2) ->结果
- Consumer 消费者。一个参数没结果:
(参数1)->void
如果是两个参数没结果的话,就是 BiConsumer(参数1, 参数2)->void
还有一个比较重要的接口是 Predicate 接口,它通常用于判断参数是否满足指定的条件。
问题代码
TestAtmoicArray.java
public class TestAtmoicArray {public static void main(String[] args) {demo(() -> new int[10],(array) -> array.length,(array, index) -> array[index]++,array -> System.out.println(Arrays.toString(array)));}private static <T> void demo(Supplier<T> arraySupplier, // 提供线程不安全/线程安全的数组Function<T, Integer> lengthFun, // 获取数组长度的方法BiConsumer<T, Integer> putConsumer, // 自增方法,回传 array 和 indexConsumer<T> printConsumer // 打印数组的方法) {List<Thread> threadList = new ArrayList<>();T array = arraySupplier.get();int length = lengthFun.apply(array);for (int i = 0; i < length; i++) {// 每个线程对数组做 10000 次操作threadList.add(new Thread(() -> {for (int j = 0; j < 10000; j++) {putConsumer.accept(array, j % length);}}));}threadList.forEach(t -> t.start()); // 启动所有线程threadList.forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}}); // 等所有线程结束printConsumer.accept(array);}
}
按照我们的预想,每个元素自增至 10000 次。但显然下面的输出结果不符合我们的预期。
[8341, 8381, 8347, 8370, 8372, 8346, 8358, 8363, 8353, 8365]
在 TestAtmoicArray.java
的 demo() 中使用原子数组
demo(() -> new AtomicIntegerArray(10),(array) -> array.length(),// AtomicIntegerArray::length(array, index) -> array.getAndIncrement(index), // AtomicIntegerArray::getAndIncrementarray -> System.out.println(array) // System.out::println
);
最终的输出结果符合预期
[10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000, 10000]
3.4.字段更新器
字段更新器可以保护某个对象里的属性(成员变量)
AtomicReferenceFieldUpdater、AtomicIntegerFieldUpdater、AtomicLongFieldUpdater
- 利用字段更新器,可以针对对象的某个域(Field)进行原子操作,只能配合 volatile 修饰的字段使用,否则会出现异常。
Exception in thread "main" java.lang.IllegalArgumentException: Must be volatile type
Student.java
public class Student {public volatile String name;@Overridepublic String toString() {return "Student{" + "name='" + name + '\'' + '}';}
}
TestAtomicFieldUpdater.java
public class TestAtomicFieldUpdater {public static void main(String[] args) {Student student = new Student();AtomicReferenceFieldUpdater updater =AtomicReferenceFieldUpdater.newUpdater(Student.class, String.class, "name");System.out.println(updater.compareAndSet(student, null, "张三"));System.out.println(student);}
}
输出结果
true
Student{name='张三'}
若我们 DEBUG 这个程序,在这个过程中间修改字段内容:Student.name = "李四"
,那么最后的输出结果则是 “李四”
3.5.累加器性能比较
TestAdder.java
public class TestAdder {public static void main(String[] args) {System.out.println("---[AtomicInteger::getAndIncrement]---");for (int i = 0; i < 5; i++) {demo(() -> new AtomicInteger(0),(adder) -> adder.getAndIncrement() // AtomicInteger::getAndIncrement);}System.out.println("---[LongAdder::increment]---");for (int i = 0; i < 5; i++) {demo(() -> new LongAdder(), // new LongAdder()adder -> adder.increment() // LongAdder::increment);}}// 此处是用 Supplier 提供一个累加器对象,Consumer 执行一个累加操作private static <T> void demo(Supplier<T> adderSupplier, Consumer<T> action) {T adder = adderSupplier.get();long start = System.nanoTime();List<Thread> ts = new ArrayList<>();// 4 个线程,每人累加 50 万for (int i = 0; i < 40; i++) {ts.add(new Thread(() -> {for (int j = 0; j < 500000; j++) {action.accept(adder);}}));}ts.forEach(t -> t.start()); // Thread::startts.forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});long end = System.nanoTime();System.out.println(adder + " cost:" + (end - start) / 1000_000);}
}
控制台输出
---[AtomicInteger::getAndIncrement]---
20000000 cost:395
20000000 cost:378
20000000 cost:329
20000000 cost:380
20000000 cost:374
---[LongAdder::increment]---
20000000 cost:55
20000000 cost:34
20000000 cost:49
20000000 cost:35
20000000 cost:39
性能提升的原因很简单,就是在有竞争时,设置多个累加单元。Therad-0 累加 Cell[0],Thread-1 累加 Cell[1] … … 最后再将结果汇总。这样它们在累加时,操作的是不同的共享变量(Cell,累加单元),因此减少了 CAS 重试失败,从而提高性能。
3.6.LongAdder 源码
LongAdder 是并发大师 @author Doug Lea
的作品,设计的非常精巧
LongAdder 类有几个关键域
// 累加单元数组, 懒惰初始化
transient volatile Cell[] cells;// 基础值, 如果没有竞争, 则用 CAS 累加这个域
transient volatile long base;// 在 cells 创建或扩容时, 置为 1, 表示加锁
transient volatile int cellsBusy;
补充知识:transient 修饰的变量是不能被序列化的
3.6.1.CAS 锁
演示用的代码(CAS 用于生产锁的一个原理)
/*** 这段代码仅仅用于演示,实际情况还请不要使用,实际操作远比这复杂* 比如这段代码块中的 while(true) 循环对性能来说就是一个不小的影响*/
@Slf4j(topic = "c.LockCAS")
public class LockCAS {// 此处设定:0 表示没加锁状态,1 表示加锁状态private AtomicInteger state = new AtomicInteger(0);public void lock() {while (true) {if (state.compareAndSet(0, 1)) {break;}}}public void unlock() {log.debug("unlock ... ...");state.set(0);}public static void main(String[] args) {LockCAS lock = new LockCAS();new Thread(() -> {log.debug("begin...");lock.lock();try {log.debug("lock...");sleep(1);} finally {lock.unlock();}},"t1").start();new Thread(() -> {log.debug("begin...");lock.lock();try {log.debug("lock...");} finally {lock.unlock();}},"t2").start();}
}
输出
14:27:23.827 [t1] DEBUG c.LockCAS - begin...
14:27:23.827 [t2] DEBUG c.LockCAS - begin...
14:27:23.837 [t1] DEBUG c.LockCAS - lock...
14:27:24.846 [t1] DEBUG c.LockCAS - unlock ... ...
14:27:24.846 [t2] DEBUG c.LockCAS - lock...
14:27:24.846 [t2] DEBUG c.LockCAS - unlock ... ...
LongAdder 源码中的字段 cellsBusy,就类似于上面讲的 CAS 锁。
cellsBusy 将来可以用来作为加锁的一个标记(0 表示未加锁,1 表示加锁),以此来保护对某些资源访问时的线程安全。
在 LongAdder 的底层,当 cells 数组被创建,或者扩容的时候,就会用到 cellBusy 这个字段。
3.6.2.原理-缓存行伪共享
其中的 Cell 即为累加单元
java/util/concurrent/atomic/Striped64.java
@sun.misc.Contended // 该注解的作用是防止缓存行伪共享
static final class Cell {volatile long value;Cell(long x) { value = x; }// 最重要的方法, 用来 cas 方式进行累加, prev 表示旧值, next 表示新值final boolean cas(long prev, long next) {return UNSAFE.compareAndSwapLong(this, valueOffset, prev, next);}// 省略不重要代码
}
要说缓存行的伪共享,那得从缓存说起。
我们于此处比较一下缓存与内存的速度。
从 CPU 到 | 大约需要的时钟周期 |
---|---|
寄存器 | 1 cycle (4 GHz 的 CPU 约为 0.25 ns) |
L1(一级缓存) | 3~4 cycle |
L2 (二级缓存) | 10~20 cycle |
L3 (三级缓存) | 40~45 cycle |
内存 | 120~240 cycle |
因为 CPU 与 内存的速度差异很大,需要靠预读数据至缓存来提升效率。
缓存以缓存行为单位,每个缓存行对应着一块内存,一般是 64 byte(8 个 long)。
缓存的加入会造成数据副本的产生,即同一份数据会缓存在不同核心的缓存行中。
CPU 要保证数据的一致性,如果某个 CPU 核心更改了数据,其它 CPU 核心对应的整个缓存行必须失效。
因为 Cell 是数组形式,在内存中是连续存储的,一个 Cell 为 24 字节(16 字节的对象头和 8 字节的 value),因此缓存行可以存下 2 个的 Cell 对象。这样问题来了: 当 Core-0 要修改 Cell[0]、Core-1 要修改 Cell[1] 时,无论谁修改成功,都会导致对方 Core 的缓存行失效。比如 Core-0 中 Cell[0]=6000
,Cell[1]=8000
要做累加操作,最终变为 Cell[0]=6001
,Cell[1]=8000
,这时 Core-1 的缓存行会失效,它又要重新去内存中找最新的值来同步。
@sun.misc.Contended
就是用来解决这个问题的,它的原理是在使用此注解的对象或字段的前后各增加 128 字节大小的 padding,从而让 CPU 将对象预读至缓存时占用不同的缓存行,这样,不会造成对方缓存行的失效。
3.6.3.源码-add
源代码:java/util/concurrent/atomic/LongAdder.java
public void increment() {add(1L);
}
源代码:java/util/concurrent/atomic/LongAdder.java
public void add(long x) {// // as 为累加单元数组、b 为基础值、x 为累加值Cell[] as; long b, v; int m; Cell a;/* * 进入 if 的两个条件* * 1. as 有值, 表示已经发生过竞争, 进入 if* * 2. cas 给 base 累加时失败了, 表示 base 发生了竞争, 进入 if*/if ((as = cells) != null || !casBase(b = base, b + x)) {boolean uncontended = true; // uncontended 表示 cell 没有竞争if (// as 还没有创建as == null || (m = as.length - 1) < 0 ||// 当前线程对应的 cell 还没有(a = as[getProbe() & m]) == null ||// CAS 给当前线程的 cell 累加失败 uncontended=false ( a 为当前线程的 cell )!(uncontended = a.cas(v = a.value, v + x)))// 进入 cell 数组创建、cell 创建的流程longAccumulate(x, null, uncontended);}}
注:cells 数组是懒惰创建的。一开始没有竞争的时候,其为 null;只有竞争发生的时候,它才会创建。
add 流程图
3.6.4.源码-longAccumulate
java/util/concurrent/atomic/Striped64.java
(部分代码有所省略)
final void longAccumulate(long x, LongBinaryOperator fn, boolean wasUncontended) {int h;// 当前线程还没有对应的 cell, 需要随机生成一个 h 值用来将当前线程绑定到 cellif ((h = getProbe()) == 0) {// 初始化 probeThreadLocalRandom.current();// h 对应新的 probe 值, 用来对应 cellh = getProbe();wasUncontended = true;}// collide 为 true 表示需要扩容boolean collide = false;for (; ; ) {Cell[] as;Cell a;int n;long v;// 已经有了 cellsif ((as = cells) != null && (n = as.length) > 0) {// 还没有 cellif ((a = as[(n - 1) & h]) == null) {// 为 cellsBusy 加锁, 创建 cell, cell 的初始累加值为 x// 成功则 break, 否则继续 continue 循环}// 有竞争, 改变线程对应的 cell 来重试 caselse if (!wasUncontended)wasUncontended = true;// cas 尝试累加, fn 配合 LongAccumulator 不为 null, 配合 LongAdder 为 nullelse if (a.cas(v = a.value, ((fn == null) ? v + x : fn.applyAsLong(v, x))))break;// 如果 cells 长度已经超过了最大长度, 或者已经扩容, 改变线程对应的 cell 来重试 caselse if (n >= NCPU || cells != as)collide = false;// 确保 collide 为 false 进入此分支, 就不会进入下面的 else if 进行扩容了else if (!collide)collide = true;// 加锁else if (cellsBusy == 0 && casCellsBusy()) {// 加锁成功, 扩容continue;}// 改变线程对应的 cellh = advanceProbe(h);}// 还没有 cells, 尝试给 cellsBusy 加锁else if (cellsBusy == 0 && cells == as && casCellsBusy()) {// 加锁成功, 初始化 cells, 最开始长度为 2, 并填充一个 cell// 成功则 break;}// 上两种情况失败, 尝试给 base 累加else if (casBase(v = base, ((fn == null) ? v + x : fn.applyAsLong(v, x))))break;}
}
longAccumulate 流程图-1
longAccumulate 流程图-2
longAccumulate 流程图-3
3.6.5.源码-sum
sum() 方法完成最终的统计操作
public long sum() {Cell[] as = cells; Cell a;long sum = base;if (as != null) {for (int i = 0; i < as.length; ++i) {if ((a = as[i]) != null)sum += a.value;}}return sum;
}
4.Unsafe
Java\jdk\jre\lib\rt.jar!\sun\misc\Unsafe.class
public final class Unsafe {private static final Unsafe theUnsafe;... ...
}
显然,该类是单例模式,theUnsafe 是它的一个变量。
4.1.获取 Unsafe 对象
Unsafe 对象提供了非常底层的,操作内存、线程的方法,Unsafe 对象不能直接调用,只能通过反射获得
public class TestUnsafe {public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");// 允许访问私有属性theUnsafe.setAccessible(true);// 该对象(unsafe)是静态的,静态成员变量从属于类,而不从属于对象,故此处传 nullUnsafe unsafe = (Unsafe) theUnsafe.get(null);System.out.println(unsafe);}
}
控制台输出
sun.misc.Unsafe@2f0e140b
4.2.CAS 相关应用
Teacher.java
@Data
public class Teacher {public volatile int id;public volatile String name;
}
TestUnsafe_2.java
public class TestUnsafe_2 {public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");theUnsafe.setAccessible(true);Unsafe unsafe = (Unsafe) theUnsafe.get(null);// 1.获取域的偏移地址long idOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("id"));long nameOffset = unsafe.objectFieldOffset(Teacher.class.getDeclaredField("name"));Teacher t = new Teacher();// 2.执行 CAS 操作// 四个参数是:要操作的对象、要操作对象的的偏移量、已经获取得旧值、想修改成的新值unsafe.compareAndSwapInt(t, idOffset, 0, 1);unsafe.compareAndSwapObject(t, nameOffset, null, "张三");// 3.验证System.out.println(t);}
}
输出结果如下(显然,赋值成功)
Teacher(id=1, name=张三)
这里再提一下上述代码中出现的 offset 吧。
offset 是对象内的偏移量,其实就是一个字段到对象头部的偏移量,通过这个偏移量可以快速定位字段。
4.3.模拟实现原子整数
Account_1.java
public interface Account_1 {// 获取余额Integer getBalance();// 取款void withdraw(Integer amount);static void demo(Account_1 account_1) {List<Thread> threads = new ArrayList<>();for (int i = 0; i < 1000; i++) {threads.add(new Thread(() -> {account_1.withdraw(10);}));}long startTime = System.nanoTime();threads.forEach(Thread::start);threads.forEach(t -> {try {t.join();} catch (InterruptedException e) {e.printStackTrace();}});long endTime = System.nanoTime();System.out.println(account_1.getBalance() + " cost:" + (endTime - startTime) / 1000_000 + " ms");}}
UnsafeAccessor.java
public class UnsafeAccessor {static Unsafe unsafe;static {try {Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");theUnsafe.setAccessible(true);unsafe = (Unsafe) theUnsafe.get(null);} catch (NoSuchFieldException | IllegalAccessException e) {throw new Error(e);}}static Unsafe getUnsafe() {return unsafe;}
}
MyAtomicInteger.java
public class MyAtomicInteger implements Account_1 {private volatile int value;private static final long valueOffset;private static final Unsafe UNSAFE;static {UNSAFE = UnsafeAccessor.getUnsafe();try {valueOffset = UNSAFE.objectFieldOffset(MyAtomicInteger.class.getDeclaredField("value"));} catch (NoSuchFieldException e) {e.printStackTrace();throw new RuntimeException(e);}}public int getValue() {return value;}public void decrement(int amount) {while (true) {int prev = this.value;int next = prev - amount;if (UNSAFE.compareAndSwapInt(this, valueOffset, prev, next)) {break;}}}public MyAtomicInteger(int value) {this.value = value;}@Overridepublic Integer getBalance() {return getValue();}@Overridepublic void withdraw(Integer amount) {decrement(amount);}
}
TestMyAtomicInteger.java
public class TestMyAtomicInteger {public static void main(String[] args) {Account_1.demo(new MyAtomicInteger(10000));}
}
输出结果
0 cost:66 ms
5.本章小结
- CAS 与 volatile
- CAS 相关 API
- 原子整数
- 原子引用
- 原子数组
- 字段更新器
- 原子累加器
- Unsafe
- 原理方面
- LongAdder 源码
- 伪共享
学习笔记:Java 并发编程④_无锁相关推荐
- Java并发编程,无锁CAS与Unsafe类及其并发包Atomic
为什么80%的码农都做不了架构师?>>> 我们曾经详谈过有锁并发的典型代表synchronized关键字,通过该关键字可以控制并发执行过程中有且只有一个线程可以访问共享资源,其 ...
- 【java并发编程】无锁并发框架disruptor
一.简介 Disruptor是一个高性能队列,研发的初衷是解决内部的内存队列的延迟问题,而不是分布式队列.基于Disruptor开发的系统单线程能支撑每秒600万订单. 使用场景:对延时要求很高的场景 ...
- 学习笔记:Java 并发编程①_基础知识入门
若文章内容或图片失效,请留言反馈. 部分素材来自网络,若不小心影响到您的利益,请联系博主删除. 视频链接:https://www.bilibili.com/video/av81461839 视频下载: ...
- 学习笔记:Java 并发编程②_管程
若文章内容或图片失效,请留言反馈. 部分素材来自网络,若不小心影响到您的利益,请联系博主删除. 视频链接:https://www.bilibili.com/video/av81461839 配套资料: ...
- 学习笔记:Java 并发编程⑥_并发工具_JUC
若文章内容或图片失效,请留言反馈. 部分素材来自网络,若不小心影响到您的利益,请联系博主删除. 视频链接:https://www.bilibili.com/video/av81461839 配套资料: ...
- 学习笔记-Java并发(一)
学习笔记-Java并发(一) 目录 学习笔记-Java并发一 目录 Executer Callable和Future 后台线程 线程加入 小计 今天看了这一篇 Java编程思想-java中的并发(一) ...
- 教你“强人锁男”——java并发编程的常用锁类型
Java 并发编程不可不知的七种锁类型与注意事项 锁是java并发编程中最重要的同步机制.锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息.锁是解决并发冲突的重要工具.在开发 ...
- 【Java并发编程的艺术】读书笔记——Java并发编程基础
学习参考资料:<Java并发编程的艺术> 文章目录 1.线程的几种状态 2.如何安全的终止线程 3.线程间通信(重要) 3.1共享内存 3.2消息传递 1.线程的几种状态 线程在运行的生命 ...
- 进阶笔记——java并发编程三特性与volatile
欢迎关注专栏:Java架构技术进阶.里面有大量batj面试题集锦,还有各种技术分享,如有好文章也欢迎投稿哦.微信公众号:慕容千语的架构笔记.欢迎关注一起进步. 前言 前面讲过使用synchronize ...
最新文章
- python增量赋值是什么_python学习记录20190122_增量赋值
- Python网络数据采集
- OpenCV 为图像转换为漫画效果
- Injector Job深入分析
- 无心剑中译奥修《顺其自然》
- python多线程锁_Python多线程互斥锁使用
- fetch body里数据为ReadableStream 解决办法
- 【翻译】steam离线时通过局域网游玩L4D2(求生之路2)
- win10c盘扩容_如何给磁盘进行扩容/拆分/合并的操作?保姆级教学
- 【BZOJ4199】品酒大会(后缀自动机)
- web网站测试点整理
- 大数据时代电视剧市场的价值观转型
- 播放和保存视频,图像倒置纠正到本地文件夹(含ffmpeg ,pyav多线程读取多路网络摄像头)
- win7系统ftp服务器密码修改,win7 ftp服务器密码
- led灯选用什么品牌的比较好?2022最新led光源品牌排行榜
- OFDM时频脉冲形状与子载波正交性的理解
- 实验一 基本逻辑门电路
- 效率倍增!12 个必知必会的 Python 数据处理技巧!
- 使用Remmina远程登录Ubuntu系统并实现文件共享(可实现类似Teamviewer功能)
- IEEE 802.15.4的信道接入机制二信标网络中的CSMA-CA算法
热门文章
- 打印孩子微信群里的作业去掉灰黑色背景
- STM32单片机蓝牙APP自动伸缩遮阳棚雨伞雨滴角度温度光强控制
- 静态语言和动态语言,解释和编译
- 刚写的QQ对对碰外挂
- 大学 软件外包——为大一学生答疑
- 今天一天下午到晚上都在研究如何刷手机,要是被领导知道我帮同学在刷手机系统,非开除我不可。还是贴出来,以后少走弯路吧...
- 华中“HackFUN”创客马拉松大赛
- 易语言excel表读写操作
- 基于ubuntu16.04部署IBM开源区块链项目-弹珠资产管理(Marbles)
- 鲜花和礼品店开店的详细流程