JAVA 学习重点整理笔记--整理中
THREAD
什么是多线程
多线程程序会额外增加CPU和内存的消耗,以及导致实现复杂.
- 线程安全性是指"永远不发生糟糕的事情",线程活跃性是指"某件正确的事情最终会发生”.
可重入的意思是线程可以重复获得它已经持有的锁。一般而言,重入锁的实现方法是:线程标识+锁的计数器
Mutex导致上下文切换 ;SpinLock属于busy-waiting类型的锁,也称自旋锁,Spinlock优点:没有昂贵的系统调用,不会导致线程的状态切换(用户态->内核态),执行速度快
- Spinlock缺点:一直占用cpu,而且在执行过程中还会锁bus总线,锁总线时其他处理器不能使用总线.
- Mutex优点:不会忙等,得不到锁会sleep.
- Mutex缺点:sleep时会陷入到内核态,需要昂贵的系统调用
在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。 在三种情况下可能会发生上下文切换:中断处理,多任务处理,用户态切换. 线程还需要一些内存来维持它本地的堆栈,也需要占用操作系统中一些资源来管理线程. 上下文切换会带来直接和间接两种因素影响程序性能的消耗. 直接消耗包括: CPU寄存器需要保存和加载, 系统调度器的代码需要执行, TLB实例需要重新加载, CPU 的pipeline需要刷掉; 间接消耗指的是多核的cache之间得共享数据变得无效了。
调用wait方法会产生如下操作:当前线程就进入阻塞状态。释放目标对象的同步锁,但是除此之外的其他锁依然由该线程持有
调用Notify会产生如下操作:随意选择一个线程,线程T必须重新获得目标对象的锁,直到有线程调用wait释放该锁
理解Java内存模型 在java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享(本文使用“共享变量”这个术语代指实例域,静态域和数组元素)。局部变量(Local variables),方法定义参数(java语言规范称之为formal method parameters)和异常处理器参数(exception handler parameters)不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:
- 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
- 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。
重排序分三种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致!
由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操做重排序。
cas底层的cpu指令 CMPXCHG •一个Atomic RMW操作,若Operand 1为Memory,则CMPXCHG指令还需要Lock指令配合 (Lock prefix);
能够告诉Compiler,不要进行Reordering,这个机制,就是Compiler Memory Barrier。
能够告诉Compiler,不要进行Reordering,这个机制,就是Compiler Memory Barrier。
在编译后的汇编中,Compiler Memory Barrier消失,CPU不能感知到Compiler Memory Barrier的存在,这点与后面提到的CPU Memory Barrier有所不同;因此CPU Memory Barrier是一条真正的指令,存在于编译后的汇编代码中;
4 CPU Reordering Types LoadLoad 读读乱序 LoadStore 读写乱序 StoreLoad 写读乱序 StoreStore 写写乱序 4种基本的CPU Memory Barriers LoadLoad Barrier LoadStore Barrier StoreLoad Barrier StoreStore Barrier
更为复杂的CPU Memory Barriers Store Barrier (Read Barrier) 所有在Store Barrier前的Store操作,必须在Store Barrier指令前执行完毕;而所有Store Barrier指令后的Store操作,必须在Store指令执行结束后才能开始;
Store Barrier只针对Store(Write)操作,对Load无任何影响;
Load Barrier (Write Barrier) 将Store Barrier的功能,全部换为针对Load操作即可;
Full Barrier Load + Store Barrier,Full Barrier两边的任何操作,均不可交换顺序;
Only CPU Memory Barrier asm volatile(“mfence”);
CPU + Compiler Memory Barrier asm volatile(“mfence” ::: ”memory”);
Question? 除了CPU本身提供的Memory Barrier指令之外,是否有其他的方式实现Memory Barrier?
Yes! We Need Lock Instruction’s Help! Reads or writes cannot be reordered with I/O instructions, locked instructions, or serializing instructions.
解读 既然read/write不能穿越locked instructions进行reordering,那么所有带有lock prefix的指令,都构成了一个天然的Full Memory Barrier;
Linux(x86, x86-64) smp_rmb() smp_wmb() smp_mb()
Read Acquire + Write Release语义,是所有锁实现的基础(Spinlock, Mutex, RWLock, ...),所有被[Read Acquire, Write Release]包含的区域,即构成了一个临界区,临界区内的指令,确保不会在临界区外运行。因此,Read Acquire又称为Lock Acquire,Write Release又称为Unlock Release;
JMM把内存屏障指令分为下列四类: 屏障类型指令示例说明LoadLoad BarriersLoad1; LoadLoad; Load2确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。StoreStore BarriersStore1; StoreStore; Store2确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。LoadStore BarriersLoad1; LoadStore; Store2确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。StoreLoad BarriersStore1; StoreLoad; Load2确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。
JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。 与程序员密切相关的happens-before规则如下:
- 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
- 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
- volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
- 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)
happens-before与JMM的关系如下图所示:
如上图所示,一个happens-before规则通常对应于多个编译器重排序规则和处理器重排序规则。对于java程序员来说,happens-before规则简单易懂,它避免程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现。 既然现代处理器,都会有mesi协议保证数据一致性,为何在你举的第一个例子里,会存在数据可见性问题呢,突然想到~
mesi协议保证缓存一致性而非数据一致性。 《Computer Architecture: A Quantitative Approach, 4th Edition》的4.2章,对缓存一致性有详细的说明。
我举的第一个例子(关于写缓冲区),来自于参考文献8的8.2.3.5。在这一章节的最后,有下面这段话: XXX 上面这段话的大意是说: 事实上,这个例子能够出现这样的结果,是因为store-buffer forwarding。当一个写操作被临时保存在一个处理器的写缓存区时,这个写操作能被该处理器自己读取到;但这个写操作对其他处理器不可见,也不能被其他处理器读取到。
通过使用本地内存这个概念来抽象CPU,内存系统和编译器的优化
数据依赖分下列三种类型: 名称代码示例说明写后读a = 1;b = a;写一个变量之后,再读这个位置。写后写a = 1;a = 2;写一个变量之后,再写这个变量。读后写a = b;b = 1;读一个变量之后,再写这个变量。 上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。 前面提到过,编译器和处理器可能会对操作做重排序。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。 as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
jmm规范对数据竞争的定义如下:在一个线程中写一个变量,在另一个线程读同一个变量,而且写和读没有通过同步来排序。 如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。
这两个32位的读/写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的读/写将不具有原子性。
我记得创建一个对象有三个步骤:1、分配内存;2、将这块内存分配给变量;3、执行构造函数。这里2、3两步的顺序是无序的,所以会产生“双重检查锁定”的问题,在JDK5及之后的版本可以把对象声明为volatile避免,因为volatile语义规定了对volatile变量的读操作必须在写操作之后执行(前提是写操作在时间上是在读操作之前),以避免无序产生不一致行为。 如果对象变量不声明为volatile,则在多线程环境下也会出现一个“凭空、无效”的引用,JMM保证不了这个安全性。
volatile变量自身具有下列特性:
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存。
- 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
下面是JMM针对编译器制定的volatile重排序规则表: 是否能重排序 第二个操作第一个操作普通读/写 volatile读 volatile写 普通读/写 NOvolatile读NONONOvolatile写 NONO 举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。 从上表我们可以看出:
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量之间重排序。
因为处理器总线会同步多个处理器对内存的并发访问(请参阅我在这个系列的第三篇中,对处理器总线工作机制的描述)。 因此在任意时间点,不可能有两个线程能同时更新X的值。
--加在数组引用上的volatile可以保证任意线程都能看到这个数组引用的最新值(但不保证数组元素的可见性)。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须要从主内存中去读取共享变量。
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) { // alternative for InterlockedCompareExchange int mp = os::is_MP(); __asm { mov edx, dest mov ecx, exchange_value mov eax, compare_value LOCK_IF_MP(mp) cmpxchg dword ptr [edx], ecx }}
intel的手册对lock前缀的说明如下:
由于在指令执行期间该缓存行会一直被锁定,其它处理器无法读/写该指令要访问的内存区域,因此能保证指令执行的原子性。这个操作过程叫做缓存锁定(cache locking),缓存锁定将大大降低lock前缀指令的执行开销,但是当多处理器之间的竞争程度很高或者指令访问的内存地址未对齐时,仍然会锁住总线。 2. 禁止该指令与之前和之后的读和写指令重排序。 3. 把写缓冲区中的所有数据刷新到内存中。
concurrent包的源代码实现,会发现一个通用化的实现模式:
- 首先,声明共享变量为volatile;
- 然后,使用CAS的原子条件更新来实现线程之间的同步;
- 同时,配合以volatile的读/写和CAS所具有的volatile读和写的内存语义来实现线程之间的通信。
memory = allocate(); //1:分配对象的内存空间ctorInstance(memory); //2:初始化对象instance = memory; //3:设置instance指向刚分配的内存地址 上面三行伪代码中的2和3之间,可能会被重排序.B线程将看到一个还没有被初始化的对象。
线程优势 l 创建时间开销远小于进程的创建。因为不需要分配用户空间和那么多初始化动作。 l 销毁线程的成本也远低于进程。 l 线程之间的切换消耗低于进程,特别是同一进程内的线程切换消耗更低。 l 线程间通信的效率比进程间通信要高,因为进城之间安全性问题需要隔离和互斥,同一进程内的线程可以共享进程资源而不需要提前获取锁。
JAVA 内存模型屏蔽掉各种硬件和操作系统的内存访问差异,以实现JAVA在各个平台都达到一致的并发效果。其主要目标是定义程序中各个变量的访问规则,变量存储到内存以及取出的各种细节。 抽象出工作内存和主内存。 工作内存可以理解为cpu的缓冲等概念。线程对变量的操作必须在工作内存里面进行。
定义8种操作来完成变量存储到内存以及取出的各种细节细节: lock 锁住主存变量,线程独享 unlock 解锁主存变量,对应lock read 从主内存中读取变量到工作内存(线程内存) load 从工作内存中读取read到的变量赋值给工作内存中的变量副本。 use 执行引擎使用到工作内存的变量(读取) assign 执行引擎对变量的修改后赋值给工作内存变量(改变) store 把工作内存中的变量放入到主内存中 write 把从工作内存中store得到的变量赋值给主内存变量。
1,成对出现。比如 read 和 write, load 和 store 2,确保assign后调用store,不允许没有assign操作,而store 3,新变量只能在主存中产生。也就是 use 和 store之前,必须有 load 和 assign 4,一次只能有一个线程lock操作,同一个线程可以多次lock(可重入) 5,lock之前,必须同步变量到主存。store 和 write 6,lock清空工作内存中的变量,重新 load 或者 assign 7,unlock不能单独使用,必须先有lock。
volatile 保证可见性,禁止重排序
前面定义了各种操作,是为了保证 原子性,可见性,有序性。
happens before ,先行发生原则用来判断 数据是否发生竞争,线程是否安全的重要依据。
1,(Program Order Rule)在同一个线程中,按代码顺序执行。也就是根据控制流,执行到哪里就是哪里。 2,(Monitor Lock Rule) unlock 操作先行发生后面的 lock ,针对同一个锁。 3,(Volatile Variable Rule) 对于一个volatileo变量的写先于后面对这个变量的读。 4,(Thread start Rule) 线程的start方法先行发生与此线程的其他动作 5,(Thread Termination Rule)线程的所有操作都优先于对此线程的终止检测。 6,(Thread Interruption Rule)对interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。 7,(Finalizer Rule) 一个对象的初始化优先与finalize方法调用 8,(Transitivity) A优先与B , B 优先于 C , 则A 优先于 C
进程是程序运行的实例,线程是cpu的基本调度单位。线程把进程的资源分配和调度执行分开。线程共享了进程的内存地址,文件句柄等,又可以独立调度。
自旋锁+pause+超过次数仍未成功转成传统方式锁+自适应调整(根据监控数据是增加自旋时间还是使用传统锁) 锁消除(synchronized new object ,local stringbuffer) 粗化(string buffer 多次append) 偏向锁,轻量级锁
CAS
FalseSharing
先第一维,后第二维 cacheline
- longs = new long[DIMENSION_1][];
for (int i = 0; i < DIMENSION_1; i++) {
longs[i] = new long[DIMENSION_2];
for (int j = 0; j < DIMENSION_2; j++) {
longs[i][j] = 0L;
}
}
远程写(Remote Write) 其实确切地说不是远程写, 而是c2得到c1的数据后, 不是为了读, 而是为了写. 也算是本地写, 只是c1也拥有这份数据的拷贝, 这该怎么办呢? c2将发出一个RFO(Request For Owner)请求, 它需要拥有这行数据的权限, 其它处理器的相应缓存行设为I, 除了它自已, 谁不能动这行数据. 这保证了数据的安全, 同时处理RFO请求以及设置I的过程将给写操作带来很大的性能消耗.
伪共享 我们从上节知道, 写操作的代价很高, 特别当需要发送RFO消息时. 我们编写程序时, 什么时候会发生RFO请求呢? 有以下两种: 1. 线程的工作从一个处理器移到另一个处理器, 它操作的所有缓存行都需要移到新的处理器上. 此后如果再写缓存行, 则此缓存行在不同核上有多个拷贝, 需要发送RFO请求了. 2. 两个不同的处理器确实都需要操作相同的缓存行
一个运行在处理器core 1上的线程想要更新变量X的值, 同时另外一个运行在处理器core 2上的线程想要更新变量Y的值. 但是, 这两个频繁改动的变量都处于同一条缓存行. 两个线程就会轮番发送RFO消息, 占得此缓存行的拥有权. 当core 1取得了拥有权开始更新X, 则core 2对应的缓存行需要设为I状态. 当core 2取得了拥有权开始更新Y, 则core 1对应的缓存行需要设为I状态(失效态). 轮番夺取拥有权不但带来大量的RFO消息, 而且如果某个线程需要读此行数据时, L1和L2缓存上都是失效数据, 只有L3缓存上是同步好的数据.从前一篇我们知道, 读L3的数据非常影响性能. 更坏的情况是跨槽读取, L3都要miss,只能从内存上加载. 表面上X和Y都是被独立线程操作的, 而且两操作之间也没有任何关系.只不过它们共享了一个缓存行, 但所有竞争冲突都是来源于共享.
Acquire语义限制了编译器优化、CPU乱序,不能将含有Acquire语义的操作之后的代码,提到含有Acquire语义的操作代码之前执行; Release语义限制了编译器优化、CPU乱序,不能将含有Release语义的操作之前的代码,推迟到含有Release语义的操作代码之后执行;
原子(atom)本意是“不能被进一步分割的最小粒子”,而原子操作(atomic operation)意为"不可被中断的一个或一系列操作” 。
假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,
多个处理器同时从各自的缓存中读取变量i,分别进行加一操作,然后分别写入系统内存当中。
所谓“缓存锁定”就是如果缓存在处理器缓存行中内存区域在LOCK操作期间被锁定,当它执行锁操作回写内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性机制会阻止同时修改被两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时会起缓存行无效,
但是有两种情况下处理器不会使用缓存锁定。第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line),则处理器会调用总线锁定。第二种情况是:有些处理器不支持缓存锁定。对于Inter486和奔腾处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
但是CAS仍然存在三大问题。ABA问题,循环时间长开销大和只能保证一个共享变量的原子操作。
ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。从Java1.5开始JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法作用是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。
public boolean compareAndSet (V expectedReference,//预期引用 V newReference,//更新后的引用 int expectedStamp, //预期标志 int newStamp) //更新后的标志循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。 从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行CAS操作。
使用线程中断
- JVM不保证阻塞方法检测到中断的速度,但在实际应用响应速度还是很快的。
- 当线程处于下列方法的调用时,interrupt方法会清除中断状态,并抛出中断异常
- 也就方法声明直接抛出InterruptedException的:Object的wait(), wait(long), or wait(long, int) 方法, Thread的join(), join(long), join(long, int), sleep(long), 或者 sleep(long, int)
- 当线程下列情形时,interrupt方法会设置中断状态,但是并不抛出中断异常
- 在 interruptible channel进行IO操作时, 该channel会被关闭,然后抛出java.nio.channels.ClosedByInterruptException.
- 线程阻塞在java.nio.channels.Selector 时,则线程会立即返回一个非0的值.就好像调用了该Selector的wakeup方法.
- 其他未说明的情况(比如在catch IE后,需要传递中断状态,则调用Thread.currentThread.interrupt(),但是此时不满足上述几种情况,则仅仅是设置线程中断状态,不会抛出IE异常)
- 传递InterruptedException
- 根本不捕获异常
- 捕获该异常,简单处理后,再次抛出该异常
- 恢复中断
- 由于jvm在抛出IE异常后,会自动将中断状态置为false.所以为了调用栈中更高层的看到这个中断,则需要执行Thread.currentThread.interrupt(),这样做仅仅是为了设置线程中断状态为true.
修复多线程同步不当的问题
- 不在线程之间共享状态变量值
- 将状态变量修改为不可变的变量
- 在访问状态变量时使用同步
设计线程安全类的三要素
- 找出构成对象状态的所有要素(看类的属性)
- 找出约束状态变量的不变性条件(也称不变式,比如状态之间的关联关系,具体例如Produce/Consume,队列满时不能放,队列空时不能取等)
- 建立对象状态的并发访问管理策略
用户线程和守护线程两者几乎没有区别,唯一的不同之处就在于虚拟机的离开:如果用户线程已经全部退出运行了,只剩下守护线程存在了,虚拟机也就退出了。 因为没有了被守护者,守护线程也就没有工作可做了,也就没有继续运行程序的必要了。
为什么要同步
- 编译器,处理器以及内存系统可能会让两条语句的机器指令交错。比如在32位机器上,b变量的高位字节先被写入,然后是a变量,紧接着才会是b变量的低位字节。
- 编译器,处理器,内存系统 可能会进行指令重排序
何登成 原子操作 汇编认识 monitor enter 可以reorder,但是不能对外可见 exit 内部操作对外可见
局部性原理
由于 CS 内存取值
插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的
Java中是如何支持多线程的
JMM
上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。 要理解happens-before必须先理解重排序。happens-before就是对重排序的限制,即哪些情况下是不能重排序的。
happens-before是比重排序、内存屏障这些概念更上层的东西,我们没有办法穷举在所有的CPU/计算机架构下重排序会如何发生,也没办法为这些重排序清晰的定义该在什么时候插入屏障来阻止重排序、刷新cache的顺序等,如果java语言提供屏障操作让java开发者自己决定何时插入屏障,那么并发代码将非常难写还很容易出错,而hb规则恰好向Java开发者屏蔽了这些特定平台的底层细节,VM的开发者遵守hb规则(他们在开发某个平台上的VM时是清晰地知道在遇到某条规则时该在哪里插入内存屏障的),Java开发者也遵守hb规则(他们知道遵守了规定就能得到想要的结果),就能保证可见性。
但是同步的含义比互斥更广。同步保证了一个线程在同步块之前或者在同步块中的一个内存写入操作以可预知的方式对其他有相同监视器的线程可见。当我们退出了同步块,我们就释放了这个监视器,这个监视器有刷新缓冲区到主内存的效果,因此该线程的写入操作能够为其他线程所见。在我们进入一个同步块之前,我们需要获取监视器,监视器有使本地处理器缓存失效的功能,因此变量会从主存重新加载,于是其它线程对共享变量的修改对当前线程来说就变得可见了。
在旧的内存模型下,访问volatile变量不能被重排序,但是,它们可能和访问非volatile变量一起被重排序。这破坏了volatile字段从一个线程到另外一个线程作为一个信号条件的手段。
Java内存模型描述了在多线程代码中哪些行为是合法的,以及线程如何通过内存进行交互。它描述了“程序中的变量“ 和 ”从内存或者寄存器获取或存储它们的底层细节”之间的关系。Java内存模型通过使用各种各样的硬件和编译器的优化来正确实现以上事情。 Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面都能正确的运行。
Java包含了几个语言级别的关键字,包括:volatile, final以及synchronized,目的是为了帮助程序员向编译器描述一个程序的并发需求。Java内存模型定义了volatile和synchronized的行为,更重要的是保证了同步的java程序在所有的处理器架构下面都能正确的运行。
更确切地说是硬件对内存访问实施了优化。一般来说,CPU指令执行的速度比从主存读取数据的速度要快2到3个数量级。显然内存子系统是整个系统的屏颈,硬件工程师使尽浑身解数想出聪明办法来使访问内存更快。首先是使用cache来加速内存访问,然而这带来了下面这些额外的复杂性:
- 当cache访问不命中时,处理仍然难逃被内存子系统拖慢的厄运。
- 在多处理器系统,必须使用协议保存cache一致性。
Store Buffer 当处理器所读取的内存是多处理器系统的共享内存时,事情变得更复杂。必须使用协议来保证,当某变量的最新值保存到CPU的cache时,其它所有CPU的cache上该变量的副本必须更改成无效状态,以在所有处理器上保持值的一致性。这种协议的缺点是CPU在写数据时,不可避免地受到了拖延。 硬件工程再度想出聪明的解决方法:将写请求缓冲到一个称为store buffer的特殊硬件队列。所有请求都放到队列里,随后CPU方便时一下子将修改请求应用内存里。 对于软件开发人员,更关心的问题时,何时谓之方便。上面的marathon程序可能会发生这样的场景,‘arrived=true‘请求已排队到store buffer,但store buffer上的请求永远都不对主存生效。因此线程A永远也看不到标志变量的新值。Oops!……
内存屏障是一种特殊的处理器指令,它指挥处理器做如下的事情:
- 刷新store buffer。
- 等待直到内存屏障之前的操作已经完成。
- 不将内存屏障后面的指令提前到内存屏障之前执行
同步关键字
synchronized 表达的语义表达的有3个:
- 原子:即通过互斥保证同时只有一个线程能够获取锁,保证数据的原子性
- 内存可见:即离开同步块后,临界区内的操作都已经刷新到内存里面
- 有序:同步块里面的代码块内部执行可以乱序,但是必须保证进入同步块和离开同步块的代码不能被乱序执行。
把变量声明为volatile类型后,编译器和运行时都会注意到这个变量是共享的,因此不会将该变量上的操作和其他内存操作进行重排序.volatile变量不会被缓存在寄存器或者处理器其他不可见的地方,因此在读取volatile类型的变量总是会返回最新写入的值
对final域,编译器和处理器要遵守两个重排序规则: 1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。 用大白话表达的话,就是说
- 在创建某个对象时,如果该对象含有final属性,那么这个final属性值必须在构造方法内完成初始化赋值,然后才能给外部对象引用。写final域的重排序规则可以确保:在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
- 读final域的重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用。
- 对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
volatile,atomic,lock、cas,synchronized,
stop会导致对象状态不一致。而suspend和resume确实会导致死锁。
中断,检查volatile 中断标识,毒丸(也是检查标识) 只需要保证共享资源的可见性的时候可以使用volatile替代,synchronized保证可操作的原子性一致性和可见性。 volatile适用于新值不依赖于就值的情形。 个volatile字段很适合作为完成某些工作的标志, 可以使用volatile的情况包括:
- 该字段不遵循其他字段的不变式。
- 对字段的写操作不依赖于当前值。
- 没有线程违反预期的语义写入非法值。
- 读取操作不依赖于其它非volatile字段的值。
线程通讯
JUC类库
要实现一个线程安全的队列有两种实现方式:一种是使用阻塞算法,另一种是使用非阻塞算法。使用阻塞算法的队列可以用一个锁(入队和出队用同一把锁)或两个锁(入队和出队用不同的锁)等方式来实现,而非阻塞的实现方式则可以使用循环CAS的方式来实现,
阻塞队列提供了四种处理方法: 方法\处理方式抛出异常返回特殊值一直阻塞超时退出插入方法add(e)offer(e)put(e)offer(e,time,unit)移除方法remove()poll()take()poll(time,unit)检查方法element()peek()不可用不可用
- ArrayBlockingQueue :一个由数组结构组成的有界阻塞队列。
- LinkedBlockingQueue :一个由链表结构组成的有界阻塞队列。
- PriorityBlockingQueue :一个支持优先级排序的无界阻塞队列。
- DelayQueue:一个使用优先级队列实现的无界阻塞队列。
- SynchronousQueue:一个不存储元素的阻塞队列。
- LinkedTransferQueue:一个由链表结构组成的无界阻塞队列。
- LinkedBlockingDeque:一个由链表结构组成的双向阻塞队列。
LinkedBlockingQueue是一个用链表实现的有界阻塞队列。此队列的默认和最大长度为Integer.MAX_VALUE。此队列按照先进先出的原则对元素进行排序。 PriorityBlockingQueue是一个支持优先级的无界队列。默认情况下元素采取自然顺序排列,也可以通过比较器comparator来指定元素的排序规则。元素按照升序排列。 DelayQueue是一个支持延时获取元素的无界阻塞队列。队列使用PriorityQueue来实现。队列中的元素必须实现Delayed接口,在创建元素时可以指定多久才能从队列中获取当前元素。只有在延迟期满时才能从队列中提取元素。我们可以将DelayQueue运用在以下应用场景:
- 缓存系统的设计:可以用DelayQueue保存缓存元素的有效期,使用一个线程循环查询DelayQueue,一旦能从DelayQueue中获取元素时,表示缓存有效期到了。
- 定时任务调度。使用DelayQueue保存当天将会执行的任务和执行时间,一旦从DelayQueue中获取到任务就开始执行,从比如TimerQueue就是使用DelayQueue实现的。
SynchronousQueue是一个不存储元素的阻塞队列。每一个put操作必须等待一个take操作,否则不能继续添加元素。SynchronousQueue可以看成是一个传球手,负责把生产者线程处理的数据直接传递给消费者线程。队列本身并不存储任何元素,非常适合于传递性场景,比如在一个线程中使用的数据,传递给另外一个线程使用,SynchronousQueue的吞吐量高于LinkedBlockingQueue 和 ArrayBlockingQueue。 LinkedTransferQueue是一个由链表结构组成的无界阻塞TransferQueue队列。相对于其他阻塞队列,LinkedTransferQueue多了tryTransfer和transfer方法。 transfer方法。如果当前有消费者正在等待接收元素(消费者使用take()方法或带时间限制的poll()方法时),transfer方法可以把生产者传入的元素立刻transfer(传输)给消费者。如果没有消费者在等待接收元素,transfer方法会将元素存放在队列的tail节点,并等到该元素被消费者消费了才返回。
使用通知模式实现。所谓通知模式,就是当生产者往满的队列里添加元素时会阻塞住生产者,当消费者消费了一个队列中的元素后,会通知生产者当前队列可用。
park这个方法会阻塞当前线程,只有以下四种情况中的一种发生时,该方法才会返回。
- 与park对应的unpark执行或已经执行时。注意:已经执行是指unpark先执行,然后再执行的park。
- 线程被中断时。
- 如果参数中的time不是零,等待了指定的毫秒数时。
- 发生异常现象时。这些异常事先无法确定。
Fork/Join框架是Java7提供了的一个用于并行执行任务的框架, 是一个把大任务分割成若干个小任务,最终汇总每个小任务结果后得到大任务结果的框架。
?假如我们需要做一个比较大的任务,我们可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,于是把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应,比如A线程负责处理A队列里的任务。但是有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。
如果让我们来设计一个Fork/Join框架,该如何设计?这个思考有助于你理解Fork/Join框架的设计。 第一步分割任务。首先我们需要有一个fork类来把大任务分割成子任务,有可能子任务还是很大,所以还需要不停的分割,直到分割出的子任务足够小。 第二步执行任务并合并结果。分割的子任务分别放在双端队列里,然后几个启动线程分别从双端队列里获取任务执行。子任务执行完的结果都统一放在一个队列里,启动一个线程从队列里拿数据,然后合并这些数据。 Fork/Join使用两个类来完成以上两件事情:
- ForkJoinTask:我们要使用ForkJoin框架,必须首先创建一个ForkJoin任务。它提供在任务中执行fork()和join()操作的机制,通常情况下我们不需要直接继承ForkJoinTask类,而只需要继承它的子类,Fork/Join框架提供了以下两个子类:
- RecursiveAction:用于没有返回结果的任务。
- RecursiveTask :用于有返回结果的任务。
- ForkJoinPool :ForkJoinTask需要通过ForkJoinPool来执行,任务分割出的子任务会添加到当前工作线程所维护的双端队列中,进入队列的头部。当一个工作线程的队列里暂时没有任务时,它会随机从其他工作线程的队列的尾部获取一个任务。
AtomicStampedReference ABA ABA问题可以使用AtomicStampedReference在做CAS操作时,一方面比较内存中的操作数与预期值是否一样,同时比较内存中的操作数的时间戳(或者修改次数)是否与预期值一样。如果本次修改操作成功,一定要修改操作数的时间戳,可以通过每次加1的方式。
原子性(lock前缀,cas),内存屏障(loadstore 等4个内存屏障) 有序性、可见性 1. cpu,编译器重排序
工作内存,主内存的可见性 打印汇编 打印类加载情况
try-finally中使用。
CAS读取内存值 比较内存值和期望值 替换内存值为要替换值 读写锁支持多个读操作并发执行,写操作只能由一个线程来操作
在一个Executor对象中使用我们的ThreadFactory
ThreadPoolExecutor
Semaphore本身是用来限制有限资源的争用。比如我要限制数据数据库连接池的连接数不超过20个,我可以在连接池的配置里面设置最大值,
1 ReentrantLock 相对于固有锁synchronized,同样是可重入的,在某些vm版本上提供了比固有锁更高的性能,提供了更丰富的锁特性,比如可中断的锁,可等待的锁,平等锁以及非块结构的加锁。从代码上尽量用固有锁,vm会对固有锁做一定的优化,并且代码可维护和稳定。只有在需要ReentrantLock的一些特性时,可以考虑用ReentrantLock实现。
synchronized无法中断一个正在等待获得锁的线程,也即多线程竞争一个锁时,其余未得到锁的线程只能不停的尝试获得锁,而不能中断
同步和互斥,资源互斥、协调竞争是要解决的因,同步是竞争协调的果。可以使用synchnized/notify/notifyAll以及Lock/Condition, CyclicBarrier/Semaphore/Countdownbatch。线程的join以及Future/FutureTask是为了解决多线程计算后的结果统计
ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment是一种可重入锁ReentrantLock,在ConcurrentHashMap里扮演锁的角色,HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组,Segment的结构和HashMap类似,是一种数组和链表结构, 一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素, 每个Segment守护者一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得它对应的Segment锁。
线程池
- 线程池是先指定core size(prestartall),然后填满队列,最后再到max size。所以要注意参数的合理设置。
- 没指定默认队列大小。
- 在线程池情况下,没有在finally中调用ThreadLocal#remove方法,或者ThreadLocal不是static变量
- 需要注意在使用await()和signal()方法时,如果你在condition上调用await()方法而却没有在这个condition上调用signal()方法,这个线程将永远睡眠下去
拒绝策略
- new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, milliseconds,runnableTaskQueue, handler); public void execute(Runnable command) { Future