热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

并发编程的艺术之读书笔记(一)

目录前言:1.并发编程的挑战2.java并发机制的底层实现原理2.1java对象头2.2锁的升级与对比总结前言:本系列是我阅读并发编程的艺术

目录

 

前言:

1. 并发编程的挑战

2. java并发机制的底层实现原理

2.1 java对象头

2.2 锁的升级与对比

总结



前言:

本系列是我阅读并发编程的艺术这本书的笔记,本篇内容是系列的第一篇。

1. 并发编程的挑战

首先我们知道,并发编程的目的是为了让程序运行得更快,但是也并不是启动更多的线程就一定能让程序最大限度的并发执行。在多线程编程中为了使程序运行得更快会遇到非常非常多的挑战,例如死锁的问题,硬件软件资源的限制的问题,上下文切换问题等等。

一起来了解一下什么是上下文切换:

CPU通过给每个线程分配时间片的方式来实现支持多线程。时间片是CPU分配给各个线程的时间,因为时间片非常短,所以CPU通过不停的切换线程执行来让我们感觉多个线程是一起执行的。CPU通过时间分配算法来循环执行任务,当前任务执行完一个时间片后会切换到下一个任务,但是在切换时会保存上一次任务的状态,这样当下次切换回来的时候可以接着上次的状态继续执行,这样的任务从保存再到加载的过程就是一次上下文切换。

我们经常说多线程高并发,但是考虑一下,多线程真的一定快吗,书中举了一个例子

private static final long count = 10001;public static void main(String[] args) throws InterruptedException {// write your code hereconcurrency();serial();}private static void concurrency() throws InterruptedException {long start = System.currentTimeMillis();Thread thread = new Thread() {@Overridepublic void run() {int a = 0;for (long i = 0; i

经过测试,实际结果是并发执行不超过百万次时,速度会比串行执行的速度要慢,为什么并发执行的速度反而会比串行执行慢呢,答案是线程有创建和上下文切换的开销。那么要如何减少上下文切换的开销呢,方法有无锁并发编程、CAS算法、使用最少的线程和使用协程。接下来简单的解释一下这些方法

  • 多线程竞争锁的时候,会引起上下文切换,所以多线程处理数据时,可以用一些方法来避免使用锁,比如将数据的ID按照Hash算法取模分段,不同线程处理不同段的数据
  • CAS算法,使用Atomic包中的原子类,底层为CAS实现,不需要加锁
  • 避免创建不需要的线程。
  • 协程:在单线程里实现多任务的调度,在单线程中维持多个任务的切换。

多线程编程避免不了要使用锁,锁是个非常有用的工具,使用锁可以避免共享资源的竞争,但是使用锁也会一些隐患,比如说有可能会引起死锁的问题,一旦产生了死锁,将造成系统功能不可用。书中也举了一个引起死锁的例子

public class Main {private static String A = "A";private static String B = "B";public static void main(String[] args) throws InterruptedException {// write your code herenew Main().deadLock();}private void deadLock() {Thread t1 = new Thread(() -> {synchronized (A) {try {Thread.sleep(2000);} catch (InterruptedException ex) {ex.printStackTrace();}synchronized (B) {System.out.println("1");}}});Thread t2 = new Thread(() -> {synchronized (B) {synchronized (A) {System.out.println("2");}}});t1.start();t2.start();}}

可以看到,t1和t2线程都在互相等待对方释放锁,这样就造成了死锁的情况。在现实中,应该不会写出这样的代码,但是在更复杂的场景中,可能会遇到这样的问题。

避免死锁的几种方法

  • 避免一个线程同时获取多个锁
  • 避免一个线程在锁内同时占用多个资源
  • 尝试使用定时锁,使用lock.tryLock(timeout)来代替内部锁机制
  • 对于数据库锁,要保证加锁和解锁在一个数据库连接内,否则会出现解锁失败的情况

2. java并发机制的底层实现原理

在多线程并发编程中,synchronized和volatile都有者重要的作用,volatile是轻量级的synchronized,它在多处理器开发中保证了共享变量的”可见性“。什么是”可见性“呢,可见性是当一个线程修改一个共享变量时,另一个线程能读到这个修改的值。如果volatile修饰使用得当的话,它比synchronized的使用和执行成本更低。

在了解volatile实现原理之前,先了解一下和它实现原理相关的CPU术语和说明

术语英文单词术语描述
内存屏障memory barries是一组处理器指令,用于实现对内存操作的顺序限制
缓冲行cache lineCPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令
原子操作atomic operations不可中断的一个或一系列操作
缓存行填充cache line fill当处理器意识别到从内存中读取操作数时可缓存的,处理器读取整个高速缓存行到适当的缓存(L1,L2,L3的或所有)
缓存命中cache hit如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
写命中write hit当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中
写缺失write misses the cache一个有效的缓存行被写入到不存在的内存区域

volatile是怎么保证可见性的呢,根据汇编代码分析,对volatile执行写操作时,会有一行lock前缀的指令,lock前缀的指令在多核处理器下会引发两件事情

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

java内存模型简图

volitale所做的,就是让jvm每次都从主内存读取和写入而不是从工作内存读取和写入。

下面再来看看synchronized关键字

在多线程编程中synchronized一直是常用关键字,以前很多人都叫它重量级锁,不过,在jdk1.6对synchronized进行了各种优化后,有些情况下他就不是那么的重量级了,主要在于jdk1.6为synchronized加入了偏向锁和轻量级锁,还有锁的升级过程,下面就来一起看一下。

先看下利用synchronized实现同步的基础,java中每一个对象都可以作为锁,具体来说就是以下三种形式

  • 对于普通方法,锁的是当前实例对象
  • 对于静态方法,锁的是当前类的Class对象
  • 对于同步代码块,锁的是Synchronized括号里配置的对象

当一个线程试图访问同步代码块时,需要先获得锁,当线程退出或者抛出异常时必须释放锁,那么锁是存在哪里的呢,锁里面存储的又是什么信息呢?

JVM通过进入和退出Monitor(监视器)来实现方法同步和代码块同步,代码块同步是使用monitorenter和monitorexit指令实现的。monitorenter指令是在编译后插入到同步代码块的开始位置,monitorexit是插入到方法结束处和异常处。JVM保证每一个monitorenter都有一个monitorexit。java中的每一个对象都有一个monitor和它关联,当一个monitor被持有后,他就处于了锁定状态。线程执行到monitor指令时,会尝试获取对象所对应的monitor的所有权,即尝试获得锁。

2.1 java对象头

由于java面向对象的思想,在JVM中需要大量存储对象,存储时为了实现一些额外的功能,需要在对象中添加一些标记字段用来增强对象的功能,这就是对象头。

在现在的64位虚拟机中,java对象头共有3部分组成(其中数组长度为可选),每部分各8个字节64位,分别是Mark word(存储对象的hashCode或锁信息等),Klass word(一个指向方法区中Class信息的指针,通过这个指针对象可以知道自己是那个类的实例),数组长度(如果当前对象是数组)。

接下来我们来深入的了解一下对象头的各个部分,首先从Mark word开始,Mark word的存储结构如下:

Mark word  64bit
   对象分代年龄偏向锁标记锁标志位锁状态
25bit unused31bit对象哈希值1bit unused4bit001无锁
54bit 线程ID2bit 偏向锁的时间戳1bit unused4bit101偏向锁
                                                          62bit 轻量级锁状态下,指向栈中锁记录的指针。00轻量级锁
                                                          62bit 重量级锁状态下,指向对象监视器Monitor的指针。10重量级锁
11GC标记

接下来是Klass word,Klass word为类指针,Klass word在32位虚拟机和64位虚拟机上的大小不同,32位虚拟机上Klass word大小为4bit,64位虚拟机上Klass word大小为8bit。

最后是数组长度,当对象是数组的时候,这部分数据的长度也随着JVM架构的不同而不同:32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启+UseCompressedOops选项,该区域长度也将由64位压缩至32位。

2.2 锁的升级与对比

jdk 1.6中,为了优化synchronized的效率,引入了“偏向锁”和“轻量级锁”这两个概念,jdk 1.6中,锁有四种状态,分别是无锁、偏向锁、轻量级锁、重量级锁。这几种状态会随着竞争情况逐渐升级,但是却不能降级,这种只能升级不能降级的策略的目的是为了提高获得锁和释放锁的效率。

1. 偏向锁

经研究发现,大多数情况下,锁不仅不存在多线程竞争情况,而且还总是由同一个线程多次获得锁,为了让线程获得锁的代价更低,所以引入了偏向锁。当一个线程访问同步块并获得锁时,会在对象头和栈帧中锁记录里存储锁偏向的的线程ID(54bit长),并将偏向锁标记置为1,代表已经上锁,当下次该线程在进入同步块时,就不用进行CAS操作来加锁,直接查看一下对象头里偏向锁标志位是否为1即可,如果为1,那就使用CAS将对象头的偏向锁指向当前线程,如果为0,那么使用CAS竞争锁。

1.1 偏向锁升级为轻量级锁

当偏向锁出现锁竞争的时候,当前线程就会判断之前拥有锁的线程现在是否还存在并且还拥有偏向锁,如果存在但不拥有偏向锁的话,就重置偏向锁并重新上锁,当存在且拥有偏向锁的时候,就会发生升级到轻量级锁,并且偏向锁会撤销,偏向锁的释放是一个耗费大量资源的事,它会等待全局安全点,然后暂停拥有偏向锁的线程,检查线程是否活着,然后进行一系列的操作最后释放掉线程上的偏向锁,所以,当应用中会大量出现锁竞争的情况时,最好是不要偏向锁,那么就可以在启动的时候设置-XX:-UseBiasedLocking = false来关闭偏向锁。

2. 轻量级锁

2.1 轻量级锁的加锁过程

线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储记录的锁空间,然后把对象头中的Mark word复制进去,官方称为Displaced Mark word。然后尝试使用CAS将对象头中的Mark word替换为指向所记录的指针。如果成功则当前线程获得锁,如果失败则表示其他线程竞争锁,当前线程将自旋(线程循环等待,不停的判断锁是否能被成功获取)来获取锁。

2.2 轻量级锁解锁

轻量级锁解锁时,会用CAS操作将Displaced Mark word替换回对象头,如果成功则表示没有竞争,失败则表示当前锁存在竞争,这时候锁就会膨胀成为重量级锁。

下面画张图来看一看

因为自旋会消耗CPU,所以为了避免不必要的自旋,一旦锁升级成了重量级锁,就不会再恢复到轻量级锁状态。

锁的优缺点对比:

优点缺点适用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法速度差距很小如果线程之间出现锁竞争,会带来额外的撤销锁的消耗使用于只有一个线程访问同步块的场景
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度如果始终得不到锁竞争的线程,就会一直自旋,消耗CPU

追求响应时间

同步块执行速度非常快

重量级锁竞争的线程会阻塞,不会自旋  

3. java原子操作的实现原理

原子操作的意思是“不可被中断的一个或一系列操作”,在多处理器上实现原子操作有点复杂,下面来看看intel处理器和java里是如何实现原子操作的。在这之前,要先了解一下相关术语

3.1 术语定义

术语名称英文解释
缓存行Cache line缓存的最小操作单位
比较并交换Compare and SwapCAS操作需要两个数值,一个旧值(期望操作的值)和一个新值,在操作期间先比较旧值有没有变化,如果没有发生变化就交换新值,如果发生变化了就不交换值
CPU流水线CPU pipelineCPU流水线的工作方式就像工厂里的流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再由这些电路单元分别执行,这样就能实现在一个CPU时钟周期完成一条指令,因此提高CPU的运算速度
内存顺序冲突Memory order violation内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中一个CPU的操作无效,当出现这个内存顺序冲突的时候,CPU必须清空流水线

3.2 处理器如何实现原子操作

32位的处理器使用基于对缓存加锁或总线加锁的方式来实现多处理器之间的原子操作,处理器首先会保证基本内存操作的原子性。处理器保证从系统内存中读取或者写入一个字节是原子的,也就是当一个处理器读取一个字节的时候,其他处理器不能访问这个字节的内存地址。处理器提供总线锁定和缓存锁定两个机制来保证复杂内存操作的原子性。

总线锁保持原子性

多个处理器同时从各自的缓存中读取变量i,分别进行加i操作,然后分别写入系统内存中。想要保证读写变量的操作是原子的,就必须保证一个CPU在读写共享变量的时候,其他的CPU不能操作缓存了该共享变量内存地址的缓存。

处理器使用总线锁,提供一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将会被阻塞住,那么该处理器就可以独占共享内存。

缓存锁保持原子性

同一时刻,我们只需要保证对某个内存地址的操作是原子性的就可以了,但是总线锁把CPU和内存之间的通信锁住了,这导致锁定期间,其他处理器不能操作其他内存地址的数据,所以开销比较大,目前的处理器会在某些场合下使用缓存锁代替总线锁。

频繁使用的内存会缓存在处理器的L1、L2、L3高速缓存里,那么原子操作就可以直接在缓存中进行,所谓缓存锁的意思就是指内存区域如果被缓存在处理器缓存行中,而且在Lock操作期间被锁定,那么它执行做操作回写到内存时,处理器不在总线上发出Lock#信号,而是修改内部的内存地址,并通过缓存一致性协议来保证操作的原子性。缓存一致性协议会阻止同时修改两个以上处理器缓存的内存区域数据。

3.3 java如何实现原子操作

java中实现CAS操作是利用了处理器提供的CMPXCHG指令。自旋CAS就是循环进行CAS操作知道成功为止。

来看一段代码

public class Counter {private AtomicInteger atomicI &#61; new AtomicInteger(0);private int i&#61;0;public static void main(String[] args) {// write your code herefinal Counter cas &#61; new Counter();List ts &#61; new ArrayList<>(600);long start &#61; System.currentTimeMillis();for (int j &#61; 0; j <100; j&#43;&#43;) {Thread t &#61; new Thread(() -> {for (int i &#61; 0; i <10000; i&#43;&#43;) {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.atomicI.get());System.out.println(System.currentTimeMillis()-start);}/*** 使用CAS实现线程安全计数器*/private void safeCount() {for (; ; ) {int i &#61; atomicI.get();boolean suc &#61; atomicI.compareAndSet(i, &#43;&#43;i);if (suc) {break;}}}private void count() {//非线程安全计数器i&#43;&#43;;}
}

java从jdk1.5开始在并发包里提供了一些原子操作的类美如AtomicInteger(原子方式更新Int值)&#xff0c;AtomicBoolean(原子方式更新boolean值)等等&#xff0c;同时这些原子操作包装类还有一些有用的工具方法&#xff0c;比如以原子方式自增和自减。

但是CAS原子操作也是有自己的问题存在的&#xff0c;首先&#xff0c;CAS会产生ABA问题&#xff0c;什么是ABA问题呢&#xff0c;因为CAS在操作值的时候要先判断值有没有发生变化&#xff0c;如果没变化就更新&#xff0c;但是问题来了&#xff0c;如果一个值原来是A&#xff0c;变成了B&#xff0c;又变成了A&#xff0c;CAS就会把它当做没有变化&#xff0c;但是实际上是变化了的。这个问题有一个解决方法就是加上版本号&#xff0c;使得每次变量更新的时候都在变量前把版本号加1&#xff0c;这样的话A-B-A就会变成1A-2B-3A。jdk1.5开始&#xff0c;Atomic包里提供了一个AtomicStampedReference来解决ABA问题。这个类的compareAndSet方法首先检查当前引用是否等于预期引用&#xff0c;并检查当前标志是否等于预期标志&#xff0c;如果全部相等&#xff0c;就用原子方式将该引用和该标志的值设置为给定的值。

CAS如果长时间不成功的话&#xff0c;会给CPU带来比较大的开销&#xff0c;这也是CAS的一大缺点&#xff0c;还有就是CAS只能对一个共享变量保证原子操作&#xff0c;但是多个共享变量就不行

总结

第一部分我们讨论了并发编程的挑战&#xff0c;研究了volatile&#xff0c;synchronized和原子操作的实现原理。java中大部分容器和框架都依赖与volitale和原子操作的实现原理&#xff0c;了解这些对我们进行并发编程大有好处。


推荐阅读
  • 本文深入探讨了Java多线程环境下的同步机制及其应用,重点介绍了`synchronized`关键字的使用方法和原理。`synchronized`关键字主要用于确保多个线程在访问共享资源时的互斥性和原子性。通过具体示例,如在一个类中使用`synchronized`修饰方法,展示了如何实现线程安全的代码块。此外,文章还讨论了`ReentrantLock`等其他同步工具的优缺点,并提供了实际应用场景中的最佳实践。 ... [详细]
  • 如何利用Java 5 Executor框架高效构建和管理线程池
    Java 5 引入了 Executor 框架,为开发人员提供了一种高效管理和构建线程池的方法。该框架通过将任务提交与任务执行分离,简化了多线程编程的复杂性。利用 Executor 框架,开发人员可以更灵活地控制线程的创建、分配和管理,从而提高服务器端应用的性能和响应能力。此外,该框架还提供了多种线程池实现,如固定线程池、缓存线程池和单线程池,以适应不同的应用场景和需求。 ... [详细]
  • Web开发框架概览:Java与JavaScript技术及框架综述
    Web开发涉及服务器端和客户端的协同工作。在服务器端,Java是一种优秀的编程语言,适用于构建各种功能模块,如通过Servlet实现特定服务。客户端则主要依赖HTML进行内容展示,同时借助JavaScript增强交互性和动态效果。此外,现代Web开发还广泛使用各种框架和库,如Spring Boot、React和Vue.js,以提高开发效率和应用性能。 ... [详细]
  • Java中不同类型的常量池(字符串常量池、Class常量池和运行时常量池)的对比与关联分析
    在研究Java虚拟机的过程中,笔者发现存在多种类型的常量池,包括字符串常量池、Class常量池和运行时常量池。通过查阅CSDN、博客园等相关资料,对这些常量池的特性、用途及其相互关系进行了详细探讨。本文将深入分析这三种常量池的差异与联系,帮助读者更好地理解Java虚拟机的内部机制。 ... [详细]
  • 本文深入解析了Java 8并发编程中的`AtomicInteger`类,详细探讨了其源码实现和应用场景。`AtomicInteger`通过硬件级别的原子操作,确保了整型变量在多线程环境下的安全性和高效性,避免了传统加锁方式带来的性能开销。文章不仅剖析了`AtomicInteger`的内部机制,还结合实际案例展示了其在并发编程中的优势和使用技巧。 ... [详细]
  • 类加载机制是Java虚拟机运行时的重要组成部分。本文深入解析了类加载过程的第二阶段,详细阐述了从类被加载到虚拟机内存开始,直至其从内存中卸载的整个生命周期。这一过程中,类经历了加载(Loading)、验证(Verification)等多个关键步骤。通过具体的实例和代码示例,本文探讨了每个阶段的具体操作和潜在问题,帮助读者全面理解类加载机制的内部运作。 ... [详细]
  • 本文详细解析了Java类加载系统的父子委托机制。在Java程序中,.java源代码文件编译后会生成对应的.class字节码文件,这些字节码文件需要通过类加载器(ClassLoader)进行加载。ClassLoader采用双亲委派模型,确保类的加载过程既高效又安全,避免了类的重复加载和潜在的安全风险。该机制在Java虚拟机中扮演着至关重要的角色,确保了类加载的一致性和可靠性。 ... [详细]
  • 深入解析Java虚拟机的内存分区与管理机制
    Java虚拟机的内存分区与管理机制复杂且精细。其中,某些内存区域在虚拟机启动时即创建并持续存在,而另一些则随用户线程的生命周期动态创建和销毁。例如,每个线程都拥有一个独立的程序计数器,确保线程切换后能够准确恢复到之前的执行位置。这种设计不仅提高了多线程环境下的执行效率,还增强了系统的稳定性和可靠性。 ... [详细]
  • 深入剖析Java中SimpleDateFormat在多线程环境下的潜在风险与解决方案
    深入剖析Java中SimpleDateFormat在多线程环境下的潜在风险与解决方案 ... [详细]
  • 深入解析CAS机制:全面替代传统锁的底层原理与应用
    本文深入探讨了CAS(Compare-and-Swap)机制,分析了其作为传统锁的替代方案在并发控制中的优势与原理。CAS通过原子操作确保数据的一致性,避免了传统锁带来的性能瓶颈和死锁问题。文章详细解析了CAS的工作机制,并结合实际应用场景,展示了其在高并发环境下的高效性和可靠性。 ... [详细]
  • 在当今的软件开发领域,分布式技术已成为程序员不可或缺的核心技能之一,尤其在面试中更是考察的重点。无论是小微企业还是大型企业,掌握分布式技术对于提升工作效率和解决实际问题都至关重要。本周的Java架构师实战训练营中,我们深入探讨了Kafka这一高效的分布式消息系统,它不仅支持发布订阅模式,还能在高并发场景下保持高性能和高可靠性。通过实际案例和代码演练,学员们对Kafka的应用有了更加深刻的理解。 ... [详细]
  • 开发日志:201521044091 《Java编程基础》第11周学习心得与总结
    开发日志:201521044091 《Java编程基础》第11周学习心得与总结 ... [详细]
  • 本指南从零开始介绍Scala编程语言的基础知识,重点讲解了Scala解释器REPL(读取-求值-打印-循环)的使用方法。REPL是Scala开发中的重要工具,能够帮助初学者快速理解和实践Scala的基本语法和特性。通过详细的示例和练习,读者将能够熟练掌握Scala的基础概念和编程技巧。 ... [详细]
  • 本文详细探讨了Java事件处理机制的核心概念与实现原理,内容浅显易懂,适合初学者逐步掌握。通过具体的示例和详细的解释,读者可以深入了解Java事件模型的工作方式及其在实际开发中的应用。 ... [详细]
  • Hadoop 2.6 主要由 HDFS 和 YARN 两大部分组成,其中 YARN 包含了运行在 ResourceManager 的 JVM 中的组件以及在 NodeManager 中运行的部分。本文深入探讨了 Hadoop 2.6 日志文件的解析方法,并详细介绍了 MapReduce 日志管理的最佳实践,旨在帮助用户更好地理解和优化日志处理流程,提高系统运维效率。 ... [详细]
author-avatar
空谷幽兰关小羽
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有