[关闭]
@TryLoveCatch 2022-04-21T07:58:34.000000Z 字数 3923 阅读 747

Java知识体系之线程相关-理论基础(3)

Java知识体系


小结

咱们先来一个之前内容的总结:

用锁的最佳实践

  1. class SafeCalc {
  2. long value = 0L;
  3. long get() {
  4. synchronized (new Object()) {
  5. return value;
  6. }
  7. }
  8. void addOne() {
  9. synchronized (new Object()) {
  10. value += 1;
  11. }
  12. }
  13. }

synchronized (new Object()) 这行代码很多同学已经分析出来了,每次调用方法get()、addOne()都创建了不同的锁,相当于无锁。这里需要你再次加深一下记忆,“一个合理的受保护资源与锁之间的关联关系应该是N:1”。只有共享一把锁才能起到互斥的作用。

  1. class Account {
  2. // 账户余额
  3. private Integer balance;
  4. // 账户密码
  5. private String password;
  6. // 取款
  7. void withdraw(Integer amt) {
  8. synchronized(balance) {
  9. if (this.balance > amt){
  10. this.balance -= amt;
  11. }
  12. }
  13. }
  14. // 更改密码
  15. void updatePassword(String pw){
  16. synchronized(password) {
  17. this.password = pw;
  18. }
  19. }
  20. }

核心问题有两点:一个是锁有可能会变化,另一个是 Integer 和 String 类型的对象不适合做锁。如果锁发生变化,就意味着失去了互斥功能。 Integer 和 String 类型的对象在JVM里面是可能被重用的,除此之外,JVM里可能被重用的对象还有Boolean,那重用意味着什么呢?意味着你的锁可能被其他代码使用,如果其他代码 synchronized(你的锁),而且不释放,那你的程序就永远拿不到锁,这是隐藏的风险。

通过这两个反例,我们可以总结出这样一个基本的原则:锁,应是私有的、不可变的、不可重用的。我们经常看到别人家的锁,都长成下面示例代码这样,这种写法貌不惊人,却能避免各种意想不到的坑,这个其实就是最佳实践。最佳实践这方面的资料推荐你看《Java安全编码标准》这本书,研读里面的每一条规则都会让你受益匪浅。

  1. // 普通对象锁
  2. private final Object
  3. lock = new Object();
  4. // 静态对象锁
  5. private static final Object lock = new Object();

锁的性能要看场景

比较while(!actr.apply(this, target));这个方法和synchronized(Account.class)的性能哪个更好。

这个要看具体的应用场景,不同应用场景它们的性能表现是不同的。在这个思考题里面,如果转账操作非常费时,那么前者的性能优势就显示出来了,因为前者允许A->B、C->D这种转账业务的并行。不同的并发场景用不同的方案,这是并发编程里面的一项基本原则;没有通吃的技术和方案,因为每种技术和方案都是优缺点和适用场景的。

竞态条件需要格外关注

竞态条件问题非常容易被忽略,contains()和add()方法虽然都是线程安全的,但是组合在一起却不是线程安全的。所以你的程序里如果存在类似的组合操作,一定要小心。

  1. void addIfNotExist(Vector v,
  2. Object o){
  3. if(!v.contains(o)) {
  4. v.add(o);
  5. }
  6. }

你需要将共享变量v封装在对象的内部,而后控制并发访问的路径,这样就能有效防止对Vector v变量的滥用,从而导致并发问题。你可以参考下面的示例代码来加深理解。

  1. class SafeVector{
  2. private Vector v;
  3. // 所有公共方法增加同步控制
  4. synchronized
  5. void addIfNotExist(Object o){
  6. if(!v.contains(o)) {
  7. v.add(o);
  8. }
  9. }
  10. }

方法调用是先计算参数

  1. while(idx++ < 10000) {
  2. set(get()+1);
  3. }

有些人认为set(get()+1);这条语句是进入set()方法之后才执行get()方法,
其实并不是这样的。方法的调用,是先计算参数,然后将参数压入调用栈之后才会执行方法体。
方法调用的过程在11这篇文章中我们已经做了详细的介绍,你可以再次重温一下。

例如,下面写日志的代码,如果日志级别设置为INFO,虽然这行代码不会写日志,但是会计算"The var1:" + var1 + ", var2:" + var2的值,因为方法调用前会先计算参数。

  1. logger.debug("The var1:" + var1 + ", var2:" + var2);

更好地写法应该是下面这样,这种写法仅仅是讲参数压栈,而没有参数的计算。使用{}占位符是写日志的一个良好习惯。

  1. logger.debug("The var1:{}, var2:{}", var1, var2);

InterruptedException异常处理需小心

  1. Thread th = Thread.currentThread();
  2. while(true) {
  3. if(th.isInterrupted()) {
  4. break;
  5. }
  6. // 省略业务代码无数
  7. try {
  8. Thread.sleep(100);
  9. }catch (InterruptedException e){
  10. e.printStackTrace();
  11. }
  12. }

该代码本意是通过isInterrupted()检查线程是否被中断了,如果中断了就退出while循环。当其他线程通过调用th.interrupt().来中断th线程时,会设置th线程的中断标志位,从而使th.isInterrupted()返回true,这样就能退出while循环了。

这看上去一点问题没有,实际上却是几乎起不了作用。原因是这段代码在执行的时候,大部分时间都是阻塞在sleep(100)上,当其他线程通过调用th.interrupt().来中断th线程时,大概率地会触发InterruptedException 异常,在触发InterruptedException 异常的同时,JVM会同时把线程的中断标志位清除,所以这个时候th.isInterrupted()返回的是false。

正确的处理方式应该是捕获异常之后重新设置中断标志位,也就是下面这样:

  1. try {
  2. Thread.sleep(100);
  3. }catch(InterruptedException e){
  4. // 重新设置中断标志位
  5. th.interrupt();
  6. }

或者:

  1. Thread th = Thread.currentThread();
  2. try {
  3. while(true) {
  4. if(th.isInterrupted()) {
  5. break;
  6. }
  7. // 省略业务代码无数
  8. Thread.sleep(100);
  9. }
  10. }catch (InterruptedException e){
  11. e.printStackTrace();
  12. }

理论值 or 经验值

对于I/O密集型应用,最佳线程数应该为:2 * CPU的核数 + 1,你觉得这个经验值合理吗?

从理论上来讲,这个经验值一定是靠不住的。但是经验值对于很多“I/O耗时 / CPU耗时”不太容易确定的系统来说,却是一个很好到初始值。

我们曾讲到最佳线程数最终还是靠压测来确定的,实际工作中大家面临的系统,“I/O耗时 / CPU耗时”往往都大于1,所以基本上都是在这个初始值的基础上增加。增加的过程中,应关注线程数是如何影响吞吐量和延迟的。一般来讲,随着线程数的增加,吞吐量会增加,延迟也会缓慢增加;但是当线程数增加到一定程度,吞吐量就会开始下降,延迟会迅速增加。这个时候基本上就是线程能够设置的最大值了。

实际工作中,不同的I/O模型对最佳线程数的影响非常大,例如大名鼎鼎的Nginx用的是非阻塞I/O,采用的是多进程单线程结构,Nginx本来是一个I/O密集型系统,但是最佳进程数设置的却是CPU的核数,完全参考的是CPU密集型的算法。所以,理论我们还是要活学活用。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注