之前粗略的看过一次这本书,但是那时候基础知识并不好,有很多地方都看得云里雾里的,这次再尝试读一遍。
第一章里提到,我们都知道如果用多线程就会导致线程的上下文切换,而这一过程是比较耗时的。减少上下文切换次数的方法主要有以下几个:
-
1、使用无锁编程。多个线程在竞争锁的时候,会引起上下文切换;所以可以在某些时候尝试不使用锁来达到线程安全的目的,比如使用ThreadLocal;
-
2、CAS。比如使用juc中的Atomic包下的一些类;
-
3、协程。不知道Java里面有没有这个,但是这是go的核心,大概是在一个线程中实现多个任务的调度,并维护这些任务的切换,所以这就是你要好好学习go的原因之一。
第二章:
volatile:
能够保证被修饰的变量具有可见性,即所有线程(可能在不同的CPU上执行)读取到的这个变量 都是一致的;因为volatile变量在被修改(写入)的时候,编译后的汇编代码会多出一行Lock开头的指令,lock前缀指令在多核处理器下会 引发两件事情:
-
1、将当前处理器缓存行(cache line,缓存的最小单位,一般为64byte)的数据写回到系统内存(memory);
-
2、这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效(缓存一致性协议,每个处理器都在嗅探总线上传播的数据来 检查自己的缓存是否已经过期)
讲了一个volatile优化的常用例子,Doug Lea在JDK1.7的并发包里增加的LinkedTransferQueue类就使用了字节追加的 策略来优化队列的性能;将共享变量(头节点和尾节点)追加到64个字节,即一个L1、L2或L3级缓存的缓存行大小,从而避免头节点和尾节点被 加载到同一个缓存行,使得头尾节点在修改时不会相互锁定。至于为什么会锁定呢?这里又回到了缓存一致性协议:当一个处理器试图修改 头节点时,会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己缓存中的尾节点,而本身队列的入队和出队 操作需要不停的修改头节点和尾节点(这是一个链表实现的队列结构),因此大大影响了队列的效率。这里要求你必须严格理解缓存一致性协议, 需要单独写一篇博客来充分理解。不过在共享变量不会被频繁的写的情况下,或者是缓存行非64byte时,就没必要追加64字节了。
synchronized:
synchronized实现同步的基础在于每一个Java对象都可以作为锁,而这个锁存储在Java对象头中,可以作用在实例方法、 静态方法或者是方法块上。实例方法锁的是当前实例对象,静态方法锁的是当前类的Class对象,方法块上锁的是synchronized括号里面配置 的对象。代码块同步使用的是monitorenter和monitorexit指令实现的,方法同步并不是(同步方法是依靠方法修饰符ACC_SYNCHRONIZED)。monitorenter指令是在编译后插入到同步代码 块开始的位置,monitorexit指令插入到方法结束处和异常处。任何对象都有一个monitor与之关联。
如果Java对象是数组类型,则JVM用3个字宽(Word)存储对象头,否则用2个字宽;多的一个字宽是存储数组长度的。在32位虚拟机中,1个 字宽就是32位,即4byte;64位虚拟机就是64位,即8byte。Java对象头主要存储的内容如下:
- 1、Mark Word:存储对象的hashcode或锁信息(包括锁的标志位,一般用2bit,是否偏向锁,用1bit)
- 2、Class Metadata Address:存储到对象类型数据的指针
- 3、Array Length:数组的长度(如果对象是数组的话)
在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化。锁一共有四种状态,级别从低到高以此是:无锁状态、偏向锁状态、 轻量级锁状态和重量级锁状态。这几个状态会随着竞争情况逐渐升级,且只能升级无法降级。
大多数情况下,锁不存在多线程竞争,而且总是由同一个线程获得,为了让线程获取锁的代价更低因引入了偏向锁,线程在进入同步块的 时候先不需要进行CAS操作来加锁,只是测试一下当前对象头的Mark Word里面是否存储着指向当前线程的偏向锁,如果是,则成功获取锁 ,否则再检查一下当前对象头中Mark Word的偏向锁标识是否已经设置:如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用 CAS操作将当前对象头的偏向锁指向该线程。这里可以看到,似乎不管是竞争锁还是将对象头的偏向锁指向线程,都是使用的CAS操作。 可以通过JVM参数来关闭偏向锁,这样程序会默认进入轻量级锁状态。
轻量级锁就比较有意思了,它的加锁过程是:线程在执行同步块代码前,JVM会先在当前线程的栈帧中创建用于存储锁记录的空间 ,并将对象头中的Mark Word复制到锁记录中,这一步叫做Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word 替换为指向锁记录的指针,如果成功则表示获取到了锁,否则表示其他线程也在竞争这个锁,当前线程会尝试自旋。
解锁过程就是相反,使用CAS操作将之前复制在锁记录中的Mark Word替换回对象头,成功则表示释放成功,失败则表示存在竞争,锁此时 会膨胀为重量级锁。
锁 优点 缺点 适用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非 如果线程之间存在锁竞争,则会带来额外的 只有一个线程访问同步块
同步方法相比仅存在纳秒级别的差距 锁撤销的消耗
轻量级锁 竞争的线程不会阻塞,而是自旋,提高了响应速度 始终得不到锁的线程会一直自旋消耗CPU 追求响应时间,同步块执行速度较快
重量级锁 线程竞争不会自旋,不消耗CPU 线程会阻塞,响应时间会变长 追求吞吐量,同步块执行速度较慢
处理器提供了缓存锁定(缓存一致性协议)和总线锁定两个机制来保证某些操作的原子性,但是注意锁总线开销较大,使用场景并不多。Java中 一般采用CAS操作和锁来保证某个操作的原子性,但需要注意的是CAS引发的ABA问题,解决方案是增加一个版本号,每次变量更新时版本号加一, JDK的Atomic包提供了一个类AtomicStampedReference来解决ABA问题,这个类的compareAndSet方法的作用是首先检查当前引 用是否等于预期引用,并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
第三章:
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序,主要分为下面三种,其中第一种是编译器重排序,后面是处理器 重排序。
- 1、编译器优化的重排序,在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 2、指令级并行的重排序,现代处理器采用了指令级并行技术将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变指令执行顺序
- 3、内存系统重排序,由于处理器使用缓存(L1、L2和L3)和读/写缓冲区,这使得Load和Store操作看上去可能是乱序的。
对于编译器重排序,JMM的编译器重排序规则会禁止特定类型的编译器重排序。而对于处理器重排序,JMM的处理器重排序规则会要求 Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers)指令,通过内存屏障来禁止特定类型的处理器重排序。
JMM属于语言级的内存模型(Java Memory Model),它确保在不同的编译器和不同的处理器平台上,通过禁止特定类型的编译器重 排序和处理器重排序,为程序员提供一致的内存可见性保证。
现在处理器都有各自的写缓冲区,临时保存向内存中写入的数据。这样,保证处理器不用等待向内存中写入数据,可以加快速度。
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止一些处理器重排序,重排序主要包括 Load-Load、Load-Store、Store-Load和Store-Store。相应的内存屏障指令也就是以上四种,它确保某个操作一定在另一个操作 之前。StoreLoad Barriers是一个"全能型"的屏障,它同时具有其他3个屏障的效果,执行这个屏障的操作比较耗时,它会要求当前 处理器将写缓冲区的全部数据刷新到内存中。
数据依赖性:两个操作访问了同一个变量,且其中一个操作为写,则这两个操作之间存在数据依赖性,主要分为读后写、写后读、写后写 三种。记住编译器和处理器在重排序时,遵守数据依赖性,不会改变两个存在数据依赖性操作的执行顺序。但是仅限于单个处理器中 的执行顺序和单个线程中的执行顺序,不同处理器和不同线程之间的数据依赖性不会被考虑(这也是程序员需要考虑线程安全的原因)。
这里有一个比较新的概念,叫做猜测(Speculation),多出现在条件控制语句中。处理器可能会先执行某个if条件后的语句,将执行结果保存在某个临时变量中,当if条件为真时,再将临时变量的值 复制回原操作变量。这也是一种很常见的重排序。因为存在if语句,代码中存在了控制依赖,而控制依赖会影响指令序列的执行并行度,猜测是用来提高并行度的。
写volatile变量会强制写入主内存,读volatile变量会让当前线程本地内存中的值置位无效,然后强制从主内存中读取。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。
需要深入理解AQS,这是实现Java锁机制的关键,AQS使用一个整型的volatile变量来维护同步状态,这是加锁解锁的关键
ReentrantLock提供了公平锁和非公平锁两种实现,公平锁和非公平锁的释放过程是一样的,都是在最后写volatile状态变量;公平锁获取时,首先会去读volatile变量。非公平锁获取时,首先 会用CAS更新volatile变量,这个操作同时具有volatile读和volatile写的内存语义。
如果我们仔细分析concurrent包的源代码实现,会发现一个通用化的实现模式。首先,声明共享变量为volatile。然后,使用CAS的原子条件更新来实现线程之间的同步。再次表明AQS的重要性。
关于Final域的内存语义这一块没有看的明白,但是看到了一条建议,被构造对象的引用在构造函数中没有"逸出",这也是 effective Java 中的一条建议之一。
happens-before的概念是大名鼎鼎的Lamport在一篇论文中提出的《Time, Clocks and the Ordering of Events In a Distributed System》,也就是paxos的提出者。Lamport使用happens-before来定义分布式系统中事件之间的偏序关系。
《JSR-133:Java Memory Model and Thread Specification》对happens-before关系的定义如下:
-
- 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第 二个之前。
-
- 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序 来执行。如果重排序之后的执行结果,与按照happens-before关系来执行的结果一致,那么这种重排序是被允许的。
上面的1)是JMM对程序员的承诺。从程序员的角度来说,可以这样理解happens-before关系:如果A happens-before B, 那么Java内存模型将向程序员保证——A操作的结果将对B可见,且A的执行顺序排在B之前。注意,这只是Java内存模型向程序员做出的 保证。
上面的2)是JMM对编译器和处理器重排序的约束原则。正如前面所言,JMM其实是在遵循一个基本原则:只要不改变程序的执行结果 (指的是单线程程序和正确同步的多线程程序),编译器和处理器怎么优化都行。JMM这么做的原因是:程序员对于这两个操作是否真的 被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。因此,happens-before关系本质上和 as-if-serial语义是一回事。
假设线程A在执行的过程中,通过执行ThreadB.start()来启动线程B;同时,假设线程A在执行ThreadB.start()之前修改了一些 共享变量,线程B在开始执行后会读取到这些共享变量;同理,假设线程A在执行的过程中,通过执行ThreadB. join()来等待线程B终 止;同时,假设线程B在终止之前修改了一些共享变量,线程A从ThreadB. join()返回后会读取到这些共享变量。
Java语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:
- 1、T是一个类,而且一个T类型的实例被创建;
- 2、T是一个类,而且T中声明的一个静态方法被调用;
- 3、T中声明的一个静态字段被赋值;
- 4、T中声明的一个静态字段被访问,而且这个字段不是一个常量字段,换句话说,如果直接引用一个类的static final常量 时,并不会引发这个类的初始化。准确来说,这个常量还必须是一个编译期常量。
- 5、T是一个顶级类,而且一个断言语句嵌套在T内部被执行。
对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间 会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了(事实上,Java语言规范允许JVM的具体实现 在这里做一些优化)。
第四章:线程
我们知道线程的各个状态之间相互切换的法则,这里需要注意一点的是,阻塞状态是线程阻塞在进入synchronized修饰的同步方法时的状态,而"阻塞"在juc包下的Lock接口中的线程确是等待状态。 因为java.concurrent包中Lock接口对于阻塞的实现都是使用LockSupport类中的相关方法。即调用LockSupport.park()方法线程是进入waiting状态,而不是blocking状态。
还有一点是,我们知道调用一个对象的wait()方法后(前提是获取到了该对象的锁),线程会进入waiting状态,并被移入该对象的等待队列,然后释放锁;二另一个线程调用notify()方法会唤醒这个线程, 将前一个线程从等待队列中移出,移入同步队列,状态由waiting—>blocked,做完这些后,线程不会马上释放锁,而是直到执行完同步块的代码后才会释放锁。
管道输入/输出流和普通的文件输入/输出流或者网络输入/输出流不同之处在于,它主要用于线程之间的数据传输,而传输的媒介为内存。管道输入/输出流主要包括了如下4种具体实现: PipedOutputStream、PipedInputStream、PipedReader和PipedWriter,前两种面向字节,而后两种面向字符。