[并发编程的艺术] 02-Java并发机制的底层实现原理
Posted wange
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[并发编程的艺术] 02-Java并发机制的底层实现原理相关的知识,希望对你有一定的参考价值。
Java代码在编译后会变成Java字节码,字节码被类加载起加载到JVM里,JVM执行字节码,最终需要转化为汇编指令在CPU上执行, Java中所使用的并发机制依赖于JVM的实现和CPU的指令.
一、volatile的应用
在多处理器开发中保证共享变量的 "可见性", 可见性的意思是: 当一个线程修改一个共享变量时,另外一个线程能够读到这个修改的值. 如果 volatile变量修饰符使用恰当的话, 它比synchronized的使用和执行成本更低, 因为它不会引起线程上下文的切换和调度.
volatile的定义与使用
Java编程语言允许线程访问共享变量, 为了确保共享变量能被准确和一致的更新, 线程应该确保通过排他锁单独获得这个变量. Java语言提供了volatile, 在某些情况下比锁要更加方便. 如果一个字段被声明为 volatile, Java线程内存模型确保所有线程看到这个变量的值是一致的.
为了提高处理速度,处理器不直接和内存通信,而是先将系统内存的数据读取到内部缓存再进行操作, 但操作完不知道何时写回到内存. 如果对声明了volatile的变量进行写操作, JVM就会向处理器发送一条Lock前缀的指令, 将这个变量所在缓存行的数据写回到内存. 但是, 就算写回到内存,如果其它处理器缓存的值还是旧的,再执行计算操作就会有问题. 所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议, 每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改, 就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读取到处理器缓存里.
Lock前缀的指令在多核处理器下会引发两件事情:
a) 将当前处理器缓存行的数据写回到系统内存
b) 这个写回内存的操作会使在其它CPU里缓存了该内存地址的数据无效.
使用较多的场景
a) 作为线程开关
b) 在懒汉式单例设计模式中,修饰对象实例, 禁止指令重排
作为线程开关示例:
public class Test3 implements Runnable{ private static volatile boolean flag = true; @Override public void run() { while (flag) { System.out.println(Thread.currentThread().getName()); } } public static void main(String[] args) throws InterruptedException { Thread thread = new Thread(new Test3()); thread.start(); TimeUnit.SECONDS.sleep(1); flag = false; } }
懒汉式单例模式示例:
public class LazySingleton { // private static LazySingleton lazySingleton = null; private static volatile LazySingleton lazySingleton = null; private LazySingleton(){} public static LazySingleton getInstance(){ /////////////////最简单的写法///////////////// // // 实例为空就实例化 // if (null == lazySingleton) { // lazySingleton = new LazySingleton(); // } // // // 否则直接返回 // return lazySingleton; /////////////////最简单的写法///////////////// // 这样去实例化,结果也不是预期的,因为第一个线程进入代码块进行实例化之后,退出代码块,随之切换到了其它线程,其它线程进入代码块 // 也会进行实例化 // if (null == lazySingleton) { // try { // TimeUnit.SECONDS.sleep(1); // } catch (InterruptedException e) { // e.printStackTrace(); // } // // synchronized (LazySingleton.class) { // lazySingleton = new LazySingleton(); // } // } // 使用双重检查保证单例的线程安全(此时也不是绝对的线程安全(指令重排序会导致不安全), // 要达到线程安全,还要给lazySingleton加上volatile关键字,禁止指令重排序 ) // // 第一个线程实例化后,离开代码块,此时即使第二个线程进入代码块,经过判断会发现实例已经存在了,所以第二个线程不会去实例化对象了 if (null == lazySingleton) { try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (LazySingleton.class) { if (null == lazySingleton) { lazySingleton = new LazySingleton(); } } } return lazySingleton; } }
懒汉式单例模式测试:
/** * 测试懒汉式单例是否线程安全 * 如果按照最简单的写法,拿到的对象并不是相同的。 * 解决方法1:给getInstance方法加上synchronized,但是这样会导致其它线程等待,消耗性能。 * 解决方法2:同步代码块 */ @Test public void f2() throws InterruptedException { for (int i = 0; i < 100; i++) { new Thread(() -> { System.out.println(LazySingleton.getInstance()); }).start(); } TimeUnit.SECONDS.sleep(2); }
二、synchronized的应用
在多线程并发编程中,synchronized一直是元老级角色, 它能保证原子性,很多人都会称呼它为 "重量级锁", 随着 Java SE 1.6对synchronized进行优化之后,有些情况下它就并不那么重了, Java SE 1.6中为了减少获得锁和释放锁带来的性能消耗, 引入了偏向锁和轻量级锁以及锁的存储结构.
Java中每一个对象都可以作为锁,具体表现为3种形式:
1) 对于普通同步方法,锁是当前实例对象(会锁住对象实例)
2) 对于静态同步方法, 锁是当前类的class对象(会锁住整个类)
3) 对于同步方法块, 锁是synchronized括号里配置的对象(会锁住括号里的对象)
当一个线程试图访问同步代码块时, 它必须得到锁, 退出或抛出异常时,必须释放锁. 对于1、2、3 这三种情况的测试:
/** * 深入理解synchronized关键字 * 保证原子性和可见性操作 * 内置锁 * 每个java对象都可以用作一个实现同步的锁,这些锁称为内置锁,线程进入同步代码块或方法块时会自动获得该锁,在退出代码块/方法块时会释放该锁 * 获得内置锁的唯一途径就是进入这个锁保护的同步代码块/方法 * 互斥锁 * 内置锁是一个互斥锁,意味着最多只有一个线程能够获得该锁,当线程A尝试去获得线程B持有的内置锁时,线程A必须等待或者阻塞,直到线程B释放这个 * 锁,如果B线程不释放这个锁,那么A线程将永远等待下去。 * * 可修饰哪些地方 * 1、可修饰实例方法、静态方法 * 实例方法:锁住对象的实例 f1 两个不同对象都锁3秒,结果是几乎同时运行结束,说明锁的是各自的对象实例 * 静态方法:锁住整个类 f2 两个对象调用m2不会同时结束,第一个线程结束3miao后第二个线程结束,说明整个类被锁类 * 实际编程中尽量少用synchronized修饰静态方法,因为它会导致整个类被锁,所有线程串行执行 * 2、可修饰代码块 * 锁住括号中的对象 m3中锁的就是lock对象,因此f3中也是串行的效果, f4是并行效果 * * @Auther: [email protected] * @Date: 2019-03-01 21:18 */ public class Test2 { private Object lock = new Object(); public void m3(){ synchronized (lock) { try { TimeUnit.SECONDS.sleep(3); System.out.println(Thread.currentThread().getName()); } catch (InterruptedException e) { e.printStackTrace(); } } } // 修饰静态方法 public synchronized static void m2(){ try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } // 修饰实例方法 public synchronized void m1() { try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName()); } @Test public void f4() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread = new Thread(() -> { class1.m3(); }); Thread thread1 = new Thread(() -> { class2.m3(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f3() throws InterruptedException { Test2 class1 = new Test2(); Thread thread = new Thread(() -> { class1.m3(); }); Thread thread1 = new Thread(() -> { class1.m3(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f2() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread = new Thread(() -> { class1.m2(); }); Thread thread1 = new Thread(() -> { class2.m2(); }); thread.start(); thread1.start(); thread.join(); thread1.join(); } @Test public void f1() throws InterruptedException { Test2 class1 = new Test2(); Test2 class2 = new Test2(); Thread thread1 = new Thread(() -> { class1.m1(); }); Thread thread2 = new Thread(() -> { class2.m1(); }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } }
锁的升级与对比
Java SE 1.6为了减少获得锁和释放锁带来的性能消耗,引入了 "偏量锁" 和 "轻量级锁", 在1.6中, 锁一共有4中状态, 级别从低到高依次是: 无锁状态、偏量锁状态、轻量级锁状态和重量级锁状态, 这几个状态会随着竞争情况逐渐升级, 锁可以升级但不能降级. 锁的优缺点对比:
三、原子操作的实现原理
不可被中断的一个或一系列操作称为原子操作, 在Java中可以通过锁和循环CAS的方式来实现原子操作.
CAS(Compare and Swap)比较并交换: CAS操作需要数据两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化在不交换.
在Java中通过锁和循环CAS的方式来实现原子操作.
1) 使用循环CAS实现原子操作
public class Counter { private AtomicInteger atomicInteger = new AtomicInteger(); private int i = 0; public static void main(String[] args) { final Counter cas = new Counter(); List<Thread> ts = new ArrayList<>(600); long start = System.currentTimeMillis(); for (int j = 0; j < 100; j++) { Thread t = new Thread(() -> { for (int i = 0; i < 10000; i++) { cas.count(); cas.safeCount(); } }); ts.add(t); } for (Thread t : ts) { t.start(); } for (Thread t : ts) { try { t.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println(cas.i); System.out.println(cas.atomicInteger.get()); System.out.println(System.currentTimeMillis() - start); } // 使用cas实现线程安全的计数器 private void safeCount(){ for (; ; ) { int i = atomicInteger.get(); boolean suc = atomicInteger.compareAndSet(i, ++i); if (suc) break; } } // 非线程安全计数器 private void count(){ i++; } }
JDK并发包里提供了一些类来支持原子操作,如 AtomicBoolean(用原子方式来更新的boolean值), AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值), 这些原子包装类还提供了有用的工具方法, 比如以原子的方式将当前值自增1或自减1.
2) CAS实现原子操作的三大问题
CAS虽然高效的解决了原子操作,但是CAS仍然存在三大问题,ABA问题、循环时间长开销大、只能保证一个共享变量的原子操作.
a) ABA问题
因为CAS需要在操作值的时候,检查值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A, 那么使用CAS进行检查时会发现它的值没有发生变化,但实际上却变化了, 解决思路就是使用版本号,在变量前面加上版本号,每次变量更新的时候把版本号加1,那么 A->B->A就会变成 1A->2B->3A, JDK1.5开始提供了AtomicStampedReference来解决ABA问题, 这个类的 compareAndSet 方法的作用是首先检查当前引用是否等于预期引用, 并且检查当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值.
public boolean compareAndSet( V expectedReference, //预期引用 V newReference, //更新后的引用 int expectedStamp, //预期标志 int newStamp //更新后的标志 )
b) 循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销.
c) 只能保证一个共享变量的原子操作
当对一个共享变量进行操作时,可以使用循环CAS的方式来保证原子操作,但是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以使用锁. 还有一个取巧的办法,就是把多个共享变量合并成一个共享变量进行操作. 比如又2个共享变量 i=2, j=a; 合并一下 ij=2a, 然后用CAS来操作ij, JDK1.5开始提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作.
3) 使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域. JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁, 除了偏向锁,JVM实现锁的方式都使用了循环CAS, 即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当他退出同步块的时候使用循环CAS释放锁.
四、小结
本章学习了 volatile、synchronized和原子操作的实现原理,Java中大部分容器和框架都依赖于volatile和原子操作的实现原理, 了解这些原理对我们进行并发编程会更有帮助.
以上是关于[并发编程的艺术] 02-Java并发机制的底层实现原理的主要内容,如果未能解决你的问题,请参考以下文章
java并发编程艺术学习第二章 java并发机制的底层实现原理 学习记录 volatile