《Java并发编程实战》-读书笔记(持续更新)
Peng's Blog 只记录和技术相关的东西

《Java并发编程实战》-读书笔记(持续更新)

2017-08-21

写在前面:

暑假最后三个坑:《深入理解Java虚拟机》、《Java并发编程实战》、《剑指offer》。

Java并发编程实战这本书,在昨天(17.8.17)晚上翻了一下,发现里面很多知识点其实我已经在项目里用到过了。嗯,说明是时候看这本书了。这是Java高级技术了,暑假结束之前看完。

加油。

第一章 简介

首先你要知道啥是并发

Concurrent。并发。

我的理解,就是使用多线程.. 当一个线程在使用CPU资源结束,然后在使用IO。这时候CPU空闲,我们就能让其它线程去使用CPU了。类似这种机制就叫并发。

现实中有很多事都是并发的,比如,当你烧水的时候,你可以选择打把农药。嗯,烧水喝打农药这两件事就是并发的。

第二章 线程安全性

##定义

线程安全性的定义:当多个线程访问某个类或者某个变量的时候,这个类始终都能表现成正确的行为,那么就称这个类是线程安全的。

一个对象是否需要线程安全,取决于它是否被多个线程访问。要使得线程,那么需要同步机制来协同对对象的访问。

Java中的主要同步机制是:关键字synchronized(加锁方式是独占锁)、volatile类型的变量、显式锁(Explicit Lock)以及原子变量。

无状态对象:既不包含任何域,也不包含任何对其他类中域的引用。由于线程访问无状态对象的行为并不会影响其它线程中操作的正确性,因此,所有的无状态对象都是线程安全的

原子性

这一小节举了个例子: i++;

虽然看上去没毛病,好像是线程安全的。但是 i++ 分为了三步:读取-修改-写入,并且其结果状态依赖于之前的状态(ps:volatile不适用这种情况)。

所以,它不满足原子性。

在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有个正式的名字:竞态条件(Race Condition)。

竞态条件

简单的说的话,大概就是.. 你和你女朋友约定在星巴克见面,但是那块地方有两个星巴克(A和B),你在A的时候她在B,然后你去找她的同时她也来这找你.. 嗯,类似死锁。

最常见的竞态条件类型是:先检查后执行,即通过一个可能失效的观测结果来决定下一步的动作

延迟初始化

这是 竞态条件 最常见的一种体现。

直接贴代码:

public class LazyInitRace{
    private xxx instance = null;
  	
 	public xxx getInstance(){
        if(instance == null)
          instance = new xxx();
      	return instance;
    }
}

。。一般很多人会写这种的代码。看上去没啥毛病,其实里面有竞态条件。嗯,设计模式的单例模式里面有批判了这种实现。

为啥说有问题呢?因为.. 假定线程A和线程B同时执行getInstance(),A看到instance是空的,就创建了一个。。但.. 线程B就不一定直到它是空了,为啥呢?因为A实例化一个instance需要很长时间,可能B检查的时候发现是空,但其实这时候A已经在创建了只是还没建好。。嗯,然后就尴尬了。

这就是最常见的一种竞态条件:延迟初始化。

复合操作

简单的说,上面那些情况的出现主要是少了原子性。要避免竞态条件问题,就必须在某个线程修改该变量时,通过某种方式防止其它线程使用这个变量,从而确保其它线程只能在修改操作完成之前或之后读取和修改状态,而不是在修改过程中间。

原子操作是指以原子的方式执行的操作.. (这句是废话)

安利Java5的java.concurrent包,这个包主要是用于并发的。然后里面有个atomic包,主要是负责原子性的。

使用demo:

private AtomicLong count = new AtomicLong(0);

基本上符合原子性的操作或者类,大多数都是线程安全的。

加锁机制

内置锁

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

线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块时自动释放锁。获得内置锁(Intrinsic Lock)的唯一途径就是进入由这个锁保护的同步代码块或方法。(这句话可能翻译的有点问题)

Java的内置锁相当于一种互斥锁,啥是互斥锁呢,大概就是最多只有一个线程能用持有这种锁。当线程A尝试获取一个由线程B持有的锁是,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B不释放这个锁,A就会永远等下去。。嗯,这就是互斥锁.. 当出现等不到的情况,那就是死锁。

由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子的方式执行。

重入

之前对重入概念的理解好像有点点错误。

最关键的一句话:”重入“意味着获取锁的操作的粒度是“线程”,而不是“调用”。

怎么理解呢?当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会被则塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个线程就会请求成功。

其实,当时的理解好像也没完全错。在一个同步方法里面调用另一个方法会成功,因为是同一个线程。

用锁来保护状态

锁能使其保护的代码路径以串行形式来访问,因此可以通过锁来构造一些协议以实现对共享状态的独占访问。

一种常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

过时的集合类Vector还有好多其它同步集合类都是用的这种模式。

还有一点,虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还是需要其它的加锁机制。

此外,将每个方法都作为同步方法,这样不太好,因为会造成活跃性问题和性能问题。线程安全必然会造成性能的下降,开销的增加。

性能和活跃性

Performance and Liveness。

当使用锁是,你应该清楚代码块中实现的功能,以及在执行该代码块时是否需要很长的时间。无论是执行计算密集的操作,还是执行某个可能阻塞的操作,如果持有锁的时间过程,那么都会带来活跃性或性能问题。

书上的建议是:当执行时间较长的计算或者可能无法快速完成的操作(例如,网络IO或控制台IO),一定不要持有锁。


第三章 对象的共享

同步,并不仅仅只包括原子性这一项内容,还有另外一个非常重要的方面:内存可见性(Memory Visibility)。

可见性

这东西比较复杂,因为它违背了我们的直觉。

在单线程环境下,如果向某个变量先写入值,然后在没有其他写入操作的情况下读取这个变量,那么总觉得能得到想要的值。嗯,这没毛病。

然而,当读操作和写操作在不同的线程下执行的时候,情况就没有那么简单了。并不是说,一个线程写完之后,另一个线程就能看到它写的内容。

所以,为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。

失效值:

在可见性里面有个概念是 失效值,就是非最新的数据。并且当数据项有两个的时候还可能出现非常奇怪的现象:一个是新的,一个是失效的。

最低安全性

当线程在没有同步的情况下读取变量时,可能会得到一个失效值,但至少这个值是由之前的某个线程设置的值,而不是一个随机的值。这种安全性保证就叫做最低安全性。

最低安全性适用于大多数变量,但存在一种意外:非volatile类型的64位数值变量(double和long)..因为,JVM允许它在读和写的时候分成两个32位操作。分两部分的话,那可能一部分是新的,一部分是失效的,这是大忌啊。所以,它们不满足最低安全性。除非用volatile来声明或者用锁来保护。

加锁和可见性

在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,为了确保某个线程写入该变量的值对于其他线程来说是可见的。

加锁的含义不仅仅局限于互斥行为,还包括内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

Volatile变量

这是一种稍微弱一点的同步机制,主要就是用于将变量的更新操作通知到其它线程

加锁机制既可以保证可见性又可以保证原子性,而volatile变量只能确保可见性。

把变量声明成volatile之后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其它内存操作一起重排序。volatile变量不会被缓存在寄存器或者对其它处理器不可见的地方,因此在读取volatile类型的变量时总会返回最新写入的数据。

因为不满足原子性,所以要是 i++ 这种东西,依赖于之前的值的话,那肯定就不能被声明成volatile了。volatile不会加锁。唯一的用处就是满足可见性。是一种轻量级的同步机制。

##发布与逸出

发布(Publish)一个对象的意思是指,使对象能够在当前作用域之外的代码中使用。

逸出(Escape)是指,当某个不应该发布的对象被发布。

发布对象的最简单方法是将对象的引用保存到一个公有的静态变量中。

安全的对象构造过程:

不要在构造过程中使用 this 引用逸出。

demo:

public class SafeListener{
    private final EventListener listener;
  	private SafeListener(){
        listener = new EventListener(){
            public void onEventListener(Event e){
                doSomething(e);
            }
        };
    }
  	public static SafeListener newInstance(EventSource source){
        SafeLstener safe = new SafeListener();
      	source.registerListener(safe.listener);
      	return safe;
    }
}

使用工厂方法来方式this使用在构造过程中逸出。

线程封闭

当访问共享的可变数据时,通常需要使用同步。一种避免同步的方式就是不共享数据。。666

如果仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭(Thread Confinement)。

线程封闭技术在Swing中非常常用,其次就是在JDBC上面。JDBC的Connection对象就是线程封闭的(JDBC规范并不要求Connection对象必须是线程安全的)。里面是单线程处理的。

Ad-hoc 线程封闭

Ad-hoc 线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。

作者建议我们少用,可以的话用更强的线程封闭技术比如栈封闭和ThreadLocal类。

栈封闭

栈封闭是线程封闭的一种特例,在栈封闭中,只能通过局部变量才能访问对象

ThreadLocal类

这是线程封闭的更规范方法,这个类能使线程中的某个值与保存值的对象关联起来

提供get()和set()方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。怎么理解呢?还是JDBC的Connection对象,防止共享,所以通常将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

不变性

满足同步需求的另一种方法是使用 不可变对象(Immutable Object)。不可变对象一定是线程安全的。

当满足以下这些条件的时候,对象才是不可变的:

  • 对象创建以后其状态就不能被修改;
  • 对象的所有域都是 final 类型;
  • 对象是正确创建的(在对象的创建期间,this引用没有逸出)。

两种很好的编程习惯:

  • 除非使用更高的可见性,否则应将所有的域都声明为私有的;
  • 除非需要某个域是可变的,否则都应该声明为final域;

安全发布

前面讲的都是如何确保对象不被发布,例如让对象封闭在线程或者另一个对象的内部。

但在某些情况,我们还是希望在多个线程间共享对象,但这时候必须确保安全地共享。

安全发布的常用模式

因为可变对象必须以安全的方式发布,这就意味着发布和使用该对象的线程时都必须使用同步。

要安全的发布一个对象,对象的引用以及对象的状态必须同时对其它线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  • 在静态初始化函数中初始化一个对象引用。
  • 将对象的引用保存到volatile类型的于或者AtomicReferance对象中。
  • 将对象的引用保存到某个正确构造对象的final类型域中。
  • 将对象的引用保存到一个由锁保护的域中。

在并发程序中使用和共享对象时,可以使用一些实用的策略,包括:

线程封闭。线程封闭的对象只能由一个线程拥有,对象被封闭在该线程中,并且只能由这个线程修改;

只读共享。在没有额外的同步的情况下,共享的只读对象可以由多个线程并发访问,但任何线程都不能修改它。

线程安全共享。线程安全的对象在其内部实现同步,因此多个线程可以通过对象的共有接口来进行访问而不需要进一步的同步。

保护对象。被保护的对象只能通过持有特定的锁来访问。保护对象包括封装在其他线程安全对象中的对象,以及已发布的并且由某个特定锁保护的对象。

第四章 对象的组合

前面几章讲的都是非常细的东西,我们需要将它们组合起来用。

设计线程安全的类

设计过程,需要包含以下几个基本要素:

  • 找出构成对象状态的所有变量;
  • 找出约束状态变量的不变性条件;
  • 建立对象状态的并发访问管理策略。

下一篇 Java I/O系统

Comments

评论功能暂停使用,如需跟作者讨论请联系底部的GitHub