java并发编程的艺术笔记1-2章

第一章 并发编程的挑战

多线程带来的上下文切换的成本

频繁的切换上下文的效率开销某些情况下还不如单线程效率高

减少上下文切换的手段:

减少上下文切换的方法有无锁并发编程、CAS算法、使用最少线程和使用协程。

  • ·无锁并发编程。多线程竞争锁时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的ID按照Hash算法取模分段,不同的线程处理不同段的数据。
  • CAS算法。Java的Atomic包使用CAS算法来更新数据,而不需要加锁。
  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这
    样会造成大量线程都处于等待状态。
  • 协程:在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换。

死锁

使用多线程带来的死锁问题会导致严重的后果,无法提供服务等.

避免死锁的几个常见方法

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

程序外部环境的限制

  • 硬件资源限制:cpu,硬盘,内存 网卡 带宽–
    可以考虑将单机扩展成集群,然后对数据和机器通过算法分配处理
  • 软件资源限制:例如mysql的连接数,redis的连接数等–可以考虑通过数据库连接池之类的技术减少申请的连接数等

第2章 Java并发机制的底层实现原理

volatile是轻量级的synchronized,如果volatile变量修饰符使用恰当
的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

volatile关键字

1.内存屏障(memory barriers)是一组处理器指令,用于实现对内存操作的顺序限制
2.缓冲行(cache line)CPU高速缓存中可以分配的最小存储单位。处理器填写缓存行时会加载整个缓存行,现代CPU需要执行几百次CPU指令
3.原子操作(atomic operations)不可中断的一个或一系列操作
4.缓存行填充(cache line fill)当处理器识别到从内存中读取操作数是可缓存的,处理器填写整个高速缓存行到适当的缓存(L1,L2,L3的或所有)
5.缓存命中(cache hit)如果进行高速缓存行填充操作的内存位置仍然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取
6.写命中(write hit)当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否存在行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中。
7.写缺失(write misses the cache)一个有效的缓存行被写入到不存在的内存区域
8.比较并交换(compare and swap)CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换
9.CPU流水线(CPU pipeline)CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中由5-6个不同功能的电路单元组成一条指令处理流水线,然后将一条X86指令分成5-6步后再由这些电路单元分别执行,这样就能实现一个CPU时钟周期完成一条指令,因此提高CPU的运算速度
10.内存顺序冲突(Memory orderviolation)内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改一个缓存行的不同部分引起其中一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线

volatile关键字的底层实现原则:

1. Lock前缀指令会引起处理器缓存回写到内存
  • 老一代的处理器都是通过锁cpu总线的方式独占内存,但是cpu是通过总线来访问内存的 锁住总线就表示其他cpu不能访问内存.效率低下.
  • 新一代的处理器都是通过锁cpu缓存的方式实现.它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为“缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存的内存区域数据。
2. 一个处理器的缓存回写到内存会导致其他处理器的缓存无效
  • IA-32处理器和Intel 64处理器使用MESI(修改、独占、共享、无效)控制协议去维护内部缓存和其他处理器缓存的一致性。多核处理器会对cpu总线做监控(嗅探技术)检测到总线的写数据的内存是否存在自己的cpu缓存里面,如果存在就将其标记为无效(缓存行失效),下次再次获取时就会重新加载内存数据(强制缓存行填充)

synchronized的实现原理与应用

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。
重量级锁的代价

java的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

  1. 如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;
  2. 如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
java对象结构中的markword

用来存放锁的相关信息.这里不做深入讨论

锁的膨胀过程
  • 偏向锁:只有一个线程进入临界区;
  • 轻量级锁:多个线程交替进入临界区;
  • 重量级锁:多个线程同时进入临界区。

假设当前存在Thread#1和Thread#2这两个用户线程,分三种情况来讨论:

  1. 情况一:只有Thread#1会进入临界区;
  2. 情况二:Thread#1和Thread#2交替进入临界区;
  3. 情况三:Thread#1和Thread#2同时进入临界区。
  • 上述的情况一是偏向锁的适用场景,此时当Thread#1进入临界区时,JVM会将lockObject的对象头Mark Word的锁标志位设为“01”,同时会用CAS操作把Thread#1的线程ID记录到Mark Word中,此时进入偏向模式。所谓“偏向”,指的是这个锁会偏向于Thread#1,若接下来没有其他线程进入临界区,则Thread#1再出入临界区无需再执行任何同步操作。也就是说,若只有Thread#1会进入临界区,实际上只有Thread#1初次进入临界区时需要执行CAS操作,以后再出入临界区都不会有同步操作带来的开销。然而情况一是一个比较理想的情况,更多时候Thread#2也会尝试进入临界区。
  • 若Thread#2尝试进入时Thread#1已退出临界区,即此时lockObject处于未锁定状态,这时说明偏向锁上发生了竞争(对应情况二),此时会撤销偏向,Mark Word中不再存放偏向线程ID,而是存放hashCode和GC分代年龄,同时锁标识位变为“01”(表示未锁定),这时Thread#2会获取lockObject的轻量级锁。因为此时Thread#1和Thread#2交替进入临界区,所以偏向锁无法满足需求,需要膨胀到轻量级锁。
  • 再说轻量级锁什么时候会膨胀到重量级锁。若一直是Thread#1和Thread#2交替进入临界区,那么没有问题,轻量锁hold住。一旦在轻量级锁上发生竞争,即出现“Thread#1和Thread#2同时进入临界区”的情况,轻量级锁就hold不住了。 (根本原因是轻量级锁没有足够的空间存储额外状态,此时若不膨胀为重量级锁,则所有等待轻量锁的线程只能自旋,可能会损失很多CPU时间)
锁的类型

所谓锁的类型其实是针对synchroinzed关键内部实现原理中jdk优化后的几个阶段或者步骤,

  • 自旋锁 乐观锁

自旋锁就是在获取不到锁的时候执行一顿时间的死循环重复获取锁,

  1. 如果同步快代码执行很快,自旋很短的时间就能得到锁大幅提升性能的,避免了线程挂起的消耗
  2. 如果同步代码执行时间很长或者同时多线程都在自旋竞争锁则会浪费大量的cpu,线程挂起会节省cpu的
  3. 自旋锁的持续时间(阈值)就是一个很关键的值,jdk5是写死的,从jdk6开始由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间
  • 偏向锁 乐观锁

是jdk6引入的一个锁的优化机制,假设只有同一个线程获得锁,第一次通过cas操作将线程id写入到markword中,下次相同线程再次访问同步代码块的时候只需要检查markword中存储的线程id是否是当前线程即可,不需要走cas操作.
如果出现另外的线程竞争锁则会膨胀会轻量锁并释放锁

  • 轻量锁 乐观锁
    轻量级锁的是假设线程存在锁竞争但是在竞争时可以通过短时间自旋获取锁
  • 重量级锁 乐观锁

Synchronized是非公平锁。 Synchronized在线程进入ContentionList时,等待的线程会先尝试自旋获取锁,如果获取不到就进入ContentionList,这明显对于已经进入队列的线程是不公平的,还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占OnDeck线程的锁资源。

java实现原子操作

1. 使用循环CAS实现原子操作

从Java 1.5开始,JDK的并发包里提供了一些类来支持原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。这些原子包装类还提供了有用的工具方法,比如以原子的方式将当前值自增1和自减1。

CAS实现原子操作的三大问题

  • 1). ABA问题

使用版本号解决A→B→A就会变成1A→2B→3A
Java 1.5开始,JDK的Atomic包里提供了一个类AtomicStampedReference解决ABA问题

  • 2). 循环时间长开销大
  • 3). 只能保证一个共享变量的原子操作

共享变量i=2,j=a,合并一下ij=2a,然后用CAS来操作ij
从Java 1.5开始,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放在一个对象里来进行CAS操作

2. 使用锁机制实现原子操作

锁机制保证了只有获得锁的线程才能够操作锁定的内存区域。JVM内部实现了很多种锁机制,有偏向锁、轻量级锁和互斥锁。有意思的是除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程想进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。

猜你喜欢

转载自blog.csdn.net/qq413041153/article/details/88734612