多线程系列八:线程安全、Java内存模型(JMM)、底层实现原理
一、线程安全
1. 怎样让多线程下的类安全起来
无状态、加锁、让类不可变、栈封闭、安全的发布对象
2. 死锁
2.1 死锁概念及解决死锁的原则
一定发生在多个线程争夺多个资源里的情况下,发生的原因是每个线程拿到了某个(某些)资源不释放,同时等待着其他线程所持有的资源。
解决死锁的原则就是确保正确的获取资源的顺序,或者获取资源时使用定时尝试机制。
2.2 常见的死锁:
简单顺序死锁:
package com.study.deadlock.bank; /** * 简单顺序死锁 * 解决办法:保证拿锁的顺序一致 * @author THINKPAD * */ public class SimpleDeadLock { //左锁 private static Object left = new Object(); //右锁 private static Object right = new Object(); private static void leftToRight() throws InterruptedException { synchronized (left){ System.out.println(Thread.currentThread().getName()+" get left"); Thread.sleep(100); synchronized (right){ System.out.println(Thread.currentThread().getName()+" get right"); } } } private static void rightToLeft() throws InterruptedException { synchronized (left){ System.out.println(Thread.currentThread().getName()+" get right-left"); Thread.sleep(100); synchronized (right){ System.out.println(Thread.currentThread().getName()+" get left-right"); } } } private static class TestThread extends Thread{ private String name; public TestThread(String name) { this.name = name; } @Override public void run(){ try { rightToLeft(); } catch (InterruptedException e) { e.printStackTrace(); } } } public static void main(String[] args) { Thread.currentThread().setName("Main"); TestThread testThread = new TestThread("testThread"); testThread.start(); try { leftToRight(); } catch (InterruptedException e) { e.printStackTrace(); } } }
动态顺序死锁:
A. 通过特殊手段保证拿锁的顺序一致
package com.study.deadlock.bank.serivice; import com.study.deadlock.bank.Account; /** * 动态顺序死锁 * @author THINKPAD * 解决办法:通过特殊手段保证拿锁的顺序一致,如获取要锁定对象的hash值,然后比较大小,先锁小的再锁大的 */ public class SafeTransfer implements ITransfer { private static Object tieLock = new Object(); @Override public void transfer(Account from, Account to, int amount) throws InterruptedException { //获取要锁定对象的hash值,然后比较大小,先锁小的再锁大的 int fromHash = System.identityHashCode(from); int toHash = System.identityHashCode(to); if(fromHash<toHash){ //先锁小的 synchronized (from){ System.out.println(Thread.currentThread().getName()+" get "+from.getName()); Thread.sleep(100); //再锁大的 synchronized (to){ System.out.println(Thread.currentThread().getName() +" get "+to.getName()); from.flyMoney(amount); to.addMoney(amount); System.out.println(from); System.out.println(to); } } } else if(toHash<fromHash){ //先锁小的 synchronized (to){ System.out.println(Thread.currentThread().getName()+" get "+to.getName()); Thread.sleep(100); //再锁大的 synchronized (from){ System.out.println(Thread.currentThread().getName() +" get "+from.getName()); from.flyMoney(amount); to.addMoney(amount); System.out.println(from); System.out.println(to); } } } else{ //hash值相等时在前面再加一把锁 synchronized (tieLock){ synchronized (to){ System.out.println(Thread.currentThread().getName()+" get "+from.getName()); Thread.sleep(100); synchronized (from){ System.out.println(Thread.currentThread().getName() +" get "+to.getName()); from.flyMoney(amount); to.addMoney(amount); } } } } } }
B. 定时轮询获取锁即定时尝试获取锁
package com.study.deadlock.bank; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * 账户信息 * @author THINKPAD * */ public class Account { private long number; private final String name; private int money; private final Lock lock = new ReentrantLock(); public Lock getLock() { return lock; } public Account(String name, int amount) { this.name = name; this.money = amount; } public String getName() { return name; } public int getAmount() { return money; } @Override public String toString() { return "Account{" + "name='" + name + '\'' + ", money=" + money + '}'; } public void addMoney(int amount){ money = money + amount; } public void flyMoney(int amount){ money = money - amount; } }
package com.study.deadlock.bank.serivice; import com.study.deadlock.bank.Account; import java.util.Random; /** * 动态顺序死锁 * @author THINKPAD * * 解决办法: * 定时轮询获取锁即定时尝试获取锁 */ public class TryLockTransfer implements ITransfer { @Override public void transfer(Account from, Account to, int amount) throws InterruptedException { Random r = new Random(); while(true){ if(from.getLock().tryLock()){ try{ System.out.println(Thread.currentThread().getName() +" get from "+from.getName()); if(to.getLock().tryLock()){ try{ System.out.println(Thread.currentThread().getName() +" get to "+to.getName()); from.flyMoney(amount); to.addMoney(amount); System.out.println(from); System.out.println(to); break; }finally { to.getLock().unlock(); } } }finally { from.getLock().unlock(); } } Thread.sleep(r.nextInt(5));//防止产生活锁 } } }
3. 活锁
概念:多个线程同时获取锁,当去拿另外的一把锁时发现被其他线程持有,觉得其他线程可能需要自己手中的锁就释放自己持有的锁,这样不断的循环就产生了活锁
4. 对性能的思考
4.1、 程序的安全性优于性能的提升
4.2、 使用多线程会带来额外的性能开销,滥用线程,有可能导致得不偿失。
4.3、 所谓性能,包含多个指标。例如“多快”:服务时间、等待时间、延迟时间;例如“多少”:吞吐量,例如可伸缩性等等。
4.4、 性能的各个指标方面,是完全独立的,有时候甚至是相互矛盾。
4.5、 所以性能的提升是个包括成本在内多方面权衡和妥协的结果。
性能优化的黄金原则:
首先保证程序正确,然后再提高运行速度(如果有确切的证据表明程序确实慢)。
5. Amdahl定律
F :程序中的串行部分,是个百分比(100%-1%),
N:cpu的个数
Speedup:指在增加cpu的情况下,程序的加速比
注意:任何程序都会有串行部分
6. 线程引入的开销
上下文的切换
内存同步
阻塞
7. 减少锁的竞争
快进快出,缩小锁的范围,将与锁无关的,有大量计算或者阻塞操作的代码移出同步范围。
减小锁的粒度,多个相互独立的状态变量可以使用多个锁来保护,每个锁只保护一个变量。
锁的分段,例如ConcurrentHashMap中的实现。
减少独占锁的使用,例如读多写少的情况下,用读写锁替换排他锁。
8. 安全的单例模式
懒汉式单例
package com.study.dcl; /** * 懒汉式单例-双重检查 * @author THINKPAD * */ public class SingleDcl { //双重检查不能保证线程安全,原因是第一个线程可能还没有初始化完, //第二个线程就进来获取单例对象使用了,所以加一个volatile修饰保证可见性 private volatile static SingleDcl single; private SingleDcl(){} public static SingleDcl getInstance(){ if(null==single){ synchronized (SingleDcl.class){ if(single==null){ single = new SingleDcl(); } } } return single; } }
饿汉式单例
package com.study.dcl; /** * 饿汉式单例-线程安全 * @author THINKPAD * */ public class SingleEHan { public static SingleEHan singleEHan = new SingleEHan(); private SingleEHan(){}; }
延迟类占位符单例
package com.study.dcl; /** * 延迟类占位符单例,利用JVM的类加载的时候会自动给加载的类加上锁的机制 * @author THINKPAD * */ public class SingleClassInit { private SingleClassInit(){} private static class InstanceHolder{ public static SingleClassInit instance = new SingleClassInit(); } public static SingleClassInit getInstance(){ return InstanceHolder.instance; } }
总结:单例建议使用延迟类占位符单例和枚举类型的单例
二、JMM
JMM: Java内存模型(Java Memory model)
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。所谓的“内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。
在JMM中如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
三、底层实现原理
1. volatile的实现原理
volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的“可见性”。
可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。
volatile是如何来保证可见性的呢?
是因为在编译的时候使用了一个Lock前缀的指令,Lock前缀的指令在多核处理器下会引发了两件事情,
1)将当前处理器缓存行的数据写回到系统内存。
2)这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。
2. synchronized的实现原理
synchronized一直是元老级角色,很多人都会称呼它为重量级锁。但是,随着Java SE 1.6对synchronized进行了各种优化之后,有些情况下它就并不那么重了。
Java中的每一个对象都可以作为锁。具体表现为以下3种形式。
·对于普通同步方法,锁是当前实例对象。
·对于静态同步方法,锁是当前类的Class对象。
·对于同步方法块,锁是Synchonized括号里配置的对象。
当一个线程试图访问同步代码块时,它首先必须得到锁,退出或抛出异常时必须释放锁。那么锁到底存在哪里呢?锁里面会存储什么信息呢?
synchronized用的锁是存在Java对象头里的。如果对象是数组类型,则虚拟机用3个字宽(Word)存储对象头,如果对象是非数组类型,则用2字宽存储对象头。在32位虚拟机中,1字宽等于4字节。
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”,在Java SE 1.6中,锁一共有4种状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态会随着竞争情况逐渐升级。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率
偏向锁
HotSpot的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如Locking、Unlocking及对Mark Word的Update等)。当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束。根据锁对象目前是否处于被锁定的状态,撤销偏向(Revoke Bias)后恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态
轻量级锁
(1)轻量级锁加锁
线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
(2)轻量级锁解锁
轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了。
重量级锁
重量锁在JVM中又叫对象监视器(Monitor),除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。
锁的优缺点对比
锁 |
优点 |
缺点 |
适用场景 |
偏向锁 |
加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距 |
如果线程间存在锁竞争,会带来额外的锁撤销的消耗 |
适用于只有一个线程访问同步块场景 |
轻量级锁 |
竞争的线程不会阻塞,提高了程序的响应速度 |
如果始终得不到锁竞争的线程使用自旋会消耗CPU |
追求响应时间,锁占用时间很短 |
重量级锁 |
线程竞争不使用自旋,不会消耗CPU |
线程阻塞,响应时间缓慢 |
追求吞吐量,锁占用时间较长 |
3. 原子操作的实现原理
使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作。首先处理器会自动保证基本的内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,意思是当一个处理器读取一个字节时,其他处理器不能访问这个字节的内存地址。但是复杂的内存操作处理器是不能自动保证其原子性的。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。
(1)使用总线锁保证原子性
第一个机制是通过总线锁保证原子性。如果多个处理器同时对共享变量进行读改写操作(i++就是经典的读改写操作),那么共享变量就会被多个处理器同时进行操作,这样读改写操作就不是原子的,操作完之后共享变量的值会和期望的不一致。处理器使用总线锁就是来解决这个问题的。所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器可以独占共享内存。
(2)使用缓存锁保证原子性
在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。缓存锁定就是当某块CPU对缓存中的数据进行操作了之后,就通知其他CPU放弃储存在它们内部的缓存,或者从主内存中重新读取。
处理器提供了很多Lock前缀的指令来实现。例如,位测试和修改指令:BTS、BTR、BTC;交换指令XADD、CMPXCHG。JVM中的CAS操作正是利用了处理器提供的CMPXCHG指令实现的。在Java中可以通过锁和循环CAS的方式来实现原子操作。
4. 重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
不管怎么重排序(编译器和处理器为了提高并行度),程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
例如:
double pi = 3.14; // A
double r = 1.0; // B
double area = pi * r * r; // C
A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
5. happens-before
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,为此jvm中提出了happens-before的概念来指定两个操作之间的执行顺序。由于这两个操作可以在一个线程之内,也可以是在不同线程之间。JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happensbefore关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)。
happens-before关系的定义如下。
1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见。
2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见。
6. volatile的内存语义
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
volatile写的内存语义如下。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义如下。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
7. 锁的内存语义
锁是Java并发编程中最重要的同步机制。锁除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。
当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。