Java并发编程实战-读书笔记_01

备注:以下内容99.9%出自 机械工业出版社出版的<<Java并发编程实战>>,此为本人读书笔记,下文内容略过了此书中本人已了解的部分内容,书中好多颜如玉还有好多黄金屋.

 

第一部分 基础知识

线程安全性

编写线程安全的代码,核心就是对于状态访问操作进行管理,特别是对共享(Shared)和可变(Mutable)状态的访问.

非正式意义上来说:对象的状态就是指存储在状态变量(例如实例或静态域)中的数据.可能包括其他对象的域.比如HashMap的状态不只存储在对象本身,还存储在许多Map.Entry对象中.对象的状态中包含了任何可能影响其外部可见行为的数据.

Java中的主要同步机制:

synchronized提供一种独占的加锁方式.

volatile,

显示锁(Explicit Lock)

原子变量

 

多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误.有三种方式可以修复这个问题.

  • 不在线程之间共享该变量.

  • 将状态变量修改为不可变的变量.

  • 访问状态变量时使用同步.

如果在设计类的时候没有考虑并发访问的情况,采用上述方法可能要对设计进行重大修改,如果一开始就设计一个线程安全的类,那么比在以后再将这个类修改为线程安全的类要容易得多.

要找出多个线程在哪些位置上将访问同一个变量是非常复杂的.不过面向对象这种技术有助于编写结构优雅,可维护性高的类,还有助于编写出线程安全的类.访问某个变量的代码越少,就越容易确保所有访问都实现正确同步,更容易找到哪些条件下变量被访问.

面向对象的抽象和封装会降低程序的性能(很少有开发人员相信),但编写并发应用程序时,正确的编程方法就是:首先保证代码正确运行,然后再提高代码速度.

如果你必须打破封装,也不是不可以实现程序的线程安全,只是更困难,而且程序的线程安全性更加脆弱,不仅增加了开发的成本和风险,也增加维护程本和风险.

并发错误非常难重现和调试,如果为了很少执行的代码路径上获得性能提升,很可能被程序运行时存在的失败风险而抵消

2.1 什么是线程安全

线程安全性定义:

当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的.

当多个线程访问某个类时,不管运行环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类是线程安全的

在线程安全类中封装必要的同步机制,客户端就无须进一步采取同步措施

 

无状态:不包含任何域,也不包含任何对其他类中域的引用.计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问.

线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的.

无状态对象一定是线程安全的.

大多数Servlet都是无状态的,极大地降低了实现Servlet线程安全性时的复杂性,只有当Servlet在处理请求时需要保存一些信息,线程安全性才会成为一个问题.

 

2.2 原子性

例子:

@NotThreadSafe
public class MyServlet implements Servlet{
​
    private long count = 0;
​
    public long getCount(){
        return count;
    }
​
    public void service(ServletRequest req,ServletResponse resp){
        ++count;        
    }
​
}

这不是一个线程安全的Servlet, ++count是"读取-修改-写入"的操作序列,并且其结果状态依赖于之前的状态,这种由于不正确的执行时序而出现不正确的结果是非常重要的情况,它有正式的名字:竞态条件(Race Condition)

静态条件这个术语很容易与另一个术语"数据竞争"相混淆.数据竞争指,如果访问共享的非final类型的域时没有采用同步来进行协同,那么就会出现数据竞争.在java内存模型中,如果在代码中存在数据竞争,那么这段代码就没有确定的语义.并非所有竞态条件都是数据竞争,同样并非所有数据竞争都是竞态条件,但二者都可以使并发程序失败

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件.换句话说,就是正确的结果取决于运气.最常见的静态条件类型就是"先检查后执行(Check-Then-Act)"操作,通过可能失效的观测结果来决定下一步的动作.

"先检查后执行"的一种常见情况就是延迟初始化.例如懒汉模式单例

为了确保线程安全性,"先检查后执行"(例如懒加载,和"读取-修改-写入")等操作统称为符合操作.包含了一组必须以原子方式执行的操作以确保线程安全性.我们将介绍枷锁机制,这是Java中用于确保原子性的内置机制.

例子:

@ThreadSafe
public class MyServlet implements Servlet{
    private final AtomicLong count = new AtomicLong(0);
​
    public long getCount(){
        return count.get();
    }
​
    public void service(ServletRequest req,ServletResponse resp){
        count.incrementAndGet();
    }
​
}

因为Servlet的状态就是这个count的状态,而AtomicLong1是线程安全的,因此这里的Servlet也是线程安全的.

当在无状态的类中添加一个状态时,如果该状态完全由线程安全的对象来管理,那么这个类仍然是线程安全的.

实际情况中,尽可能使用现有的线程安全对象(如AcomicLong)来管理类的状态.因为判断线程安全对象的可能状态以及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性.

 

2.3 加锁机制

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet{
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
​
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
​
    public void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if( i.equals(lastNumber.get())){
            encodeIntoResponse(resp, lastFactors.get());
        } else {
            BigInteger[] factors = factors(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
​
}

无法保证同时更新lastNumber和引用的情况下,尽管对set方法的每次调用都是原子的,但仍然无法同时更新lastNumber和lastFactors.而且也无法保证同时获取这两个值.

要保持状态一致性,就需要在单个原子操作中更新所有相关的状态变量.

[1]  AtomicLong是替代Long类型整数的线程安全类,AtomicReference是一种替代对象引用的线程安全类.

2.3.1 内置锁

java提供的一种内置的锁机制来支持原子性:同步代码块(Synchronized Block)

同步代码块包括两部分:

  • 作为锁的对象引用

  • 由这个锁保护的代码块

修饰方法时,就是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象.如果是静态方法,以Class对象作为锁.

synchronized (lock){
    //锁保护
}

每个Java对象都可以用作一个实现同步的锁,被称为内置锁(Intrinsic Lock)或监视器锁(Monitor Lock).线程会在进入同步代码块之前自动获得锁,退出同步代码块时自动释放锁,无论是正常退出还是抛出异常退出.获得锁的唯一途径是进入由这个锁保护的同步代码块或方法.

Java的内置锁相当于一种互斥体(互斥锁),最多只有一个线程能持有这种锁.A线程获取B线程持有的锁时,A线程必须等待或阻塞,直到B线程释放这个锁.如果B永远不释放,A会永远等下去(真专一)

@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet{
    private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
​
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
​
    public synchronized void service(ServletRequest req, ServletResponse resp){
        BigInteger i = extractFromRequest(req);
        if( i.equals(lastNumber.get())){
            encodeIntoResponse(resp, lastFactors.get());
        } else {
            BigInteger[] factors = factors(i);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }
​
}

这是线程安全的,不过这种方法过于极端,多个客户端无法同时使用因数分解Servlet,服务响应性非常低,令人无法忍受.

2.3.2 重入

内置锁是可重入的,意味着获取锁的操作粒度是"线程",而不是"调用".

有一种实现重入锁的方法,每个锁关联一个获取计数值和一个所有者线程.计数值为0时,这个锁被认为没有被任何线程持有.如果同一个线程再次获取这个锁,计数值递增,退出同步代码块时,计数值递减,当值为0时,这个锁被释放.

为什么有重入锁?

如果父类的同步方法被子类改写,然后子类的这个方法调用父类的这个方法则会导致死锁.子类会等待一个永远无法获得的锁,永远等待下去.

如果内置锁不可重入,这段代码将死锁:

public class Widget{
    public synchronized void doSomething(){
        //...
    }
}
public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        System.out.println(toString() + ": calling doSomething");
        super.doSomething();
    }
}

 

2.4 用锁来保护状态

访问共享状态的复合操作,例如命中计数器的递增操作(读取-修改-写入)或者延迟初始化(先检查后执行),都必须是原子操作以避免产生竞态条件.复合操作的执行过程中持有一个锁,那么会使复合操作成为原子操作.不过如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置都需要使用同步,而且使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁.

常见的错误人为,只有在写入共享变量时才需要使用同步,然而事实并非如此.

可变状态变量在访问它时都需要持有同一个锁,我们称状态变量是由这个锁保护的.

 

对象内置锁与其状态没有内在关联.对象的域并不一定要通过内置锁来保护.当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某线程获得对象锁之后,只能阻止其他线程获得同一个锁,之所以每个对象都有一个内置锁,只是为了免去显式地创建锁对象.你需要自行构造加锁协议或者同步策略来实现共享状态的安全访问,并且在程序中自始至终地使用它们.

一种常见的加锁约定:

将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得该对象上不会发生并发访问.比如Vector和其他的同步集合类.对象状态中的所有变量都由对象的内置锁保护起来.然而,这种模式没有任何特殊之处,编译器或运行时不会强制实施这种模式.如果添加新的方法或代码路径时忘记了使用同步,那么这种加锁协议会很容易被破坏.

对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护.

如果不加区别的滥用synchronized,可能导致程序出现过多的同步.此外如果只是将每个方法都作为同步,并不能保证复合操作都是原子的:

if(!vector.contains(element))
    vector.add(element);

虽然contains和add都是原子的,但是上面仍然存在竞态条件.此外每个方法如果都作为同步方法,还可能导致活跃性问题(Liveness)或性能问题(Performance).

2.5 活跃性与性能

给Servlet的service整个方法加synchronized的方法简单,粗粒度,可以保证线程安全性,但是付出的代价太高.

因为synchronized方法,所以每次只有一个线程可以执行.这背离了Servlet框架的初衷,即servlet需要能同时处理多个请求.

需要缩小同步代码块的作用范围,同时又要确保同步代码块不要过小,不要将本应是原子的操作拆分到多个同步代码块中.应尽量将不影响共享状态且执行时间较长的操作从同步代码块中分离出去.

​
@ThreadSafe
public class CachedFactorizer implements Servlet {
    private BigInteger lastNumber;
    private BigInteger[] lastFactors;
    private long hits;
    private long cacheHits;
​
    public synchronized long getHits() {
        return hits;
    }
​
    public synchronized double getCacheHitRatio() {
        return (double) cacheHits / (double) hits;
    }
​
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        synchronized (this) {
            ++hits;
            if (i.equals(lastNumber)) {
                ++cacheHits;
                factors = lastFactors.clone();
            }
        }
        if (factors == null) {
            factors = factor(i);
            synchronized (this) {
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
​
}

上面代码中,不再使用原子类型AtomicLong来做计数器,而使用long类型的变量.因为我们已经使用了同步代码块来构造原子操作,而且使用两个不同的同步机制不仅混乱,也不会在性能或安全性上有任何好处,因此这里不使用原子变量.

获取,释放锁等操作需一定的开销,代码块分解过细,通常并不好.判断同步代码块的合理大小,上述代码在简单性与性能之间找到了平衡.

简单性,性能之间存在相互制约因素,一定不要盲目地为了性能牺牲简单性(可能会破坏掉最根本最不可破坏的安全性)

使用锁时,要清楚代码实现的功能,代码块执行时需要多长时间,如果持有锁的时间过长,那么都会带来活跃性或性能问题.

执行时间较长的计算或者可能无法快速完成的操作时(例如网络I/O或控制台I/O),一定不要持有锁.

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值