Fork me on GitHub

Java并发编程的艺术(1)

并发编程的挑战

上下文切换

CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下一次切换回这个任务时,可以再加载这个任务的状态,所以任务从保存到再加载的过程就是一次上下文切换

如何减少上下文切换

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

避免死锁的常见方法

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

CAS算法

Java多线程—>线程安全问题—>可见性、有序性和原子性(Java内存模型已解决前两个,锁解决最后一个)

锁分为悲观锁和乐观锁

  • 悲观锁:指的是数据被外界(包括本系统当前的其他事务,以及来自外部系统的事务处理)修改持保守态度,因此在整个数据处理过程中,将数据处于锁定状态。synchronized就是一种悲观锁。
  • 乐观锁:每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。CAS就是乐观锁的一种。

乐观锁认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则返回用户错误的信息,让用户决定如何去做。实现细节就是冲突检测和数据更新

CAS

CAS:当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其他线程都失败,失败的线程不会被挂起,而是被告知在这次竞争中失败了,并且可以再次尝试。

CAS操作包含三个操作数—内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器就会自动将该位置值更新为新值,否则处理器不做任何操作。
无论哪一种情况,它都会在CAS指令之前返回该位置的值。在一些特殊情况下将仅返回CAS是否成功,而不去提取当前值。
CAS有效说明了我认为位置V应该包含值A;如果包含该值,则把B放在这个位置,否则不去更改这个位置,只是告诉我这个位置上现在的值就行了

ABA问题

CAS先进行比较再进行替换,检查值没有发生变化就进行更新。但是如果一个值由A–>B–>A的话。使用CAS是不会检查出发生变化,解决的办法是使用版本号,A–>B–>A就会变成1A–>2B–>3A。

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

Java代码编译–>Java字节码–>类加载器加载到JVM–>JVM执行字节码–>最终转化为汇编指令在CPU上执行。

volatile的应用

volatile保证了共享变量的”可见性“,
可见性:当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。
共享变量:如果一个变量同时在多个线程的工作内存中存在副本,那么这个变量就叫共享变量

volatile:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一直地更新,线程应该确保通过排它锁单独获得这个变量。

内存屏障:一组处理器指令,用于实现对内存操作的顺序限制
原子操作:不可中断的一个或一系列操作.

volatile如何保证可见性?

volatile修饰的共享变量在生成的汇编指令中会多处一个Lock前缀的指令,那么这个Lock前缀指令会做什么呢?

  1. 将当前处理器缓存行(缓存中可以分配的最小存储单位)的数据写回系统内存
  2. 这个写回内存的操作会使得其他缓存行缓存了该内存地址的数据无效
  3. 这样其他处理器就会重新读取从系统内存中这个内存地址的数据。实现了可见性

缓存一致性协议:每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

volatile的两条实现原则

  • Lock 前缀指令会引起处理器缓存回写到内存

对于 Intel486 和 Pentium 处理器,在锁操作时,总是在总线上声言LOCK#信号。
但是在P6和目前的处理器中,如果访问的内存区域已经缓存在处理器内部,则不会声言LOCK#信号,相反,它会锁定这块内存区域的缓存并回写到内存,并使用缓存一致性机制来确保修改的原子性,此操作被称为”缓存锁定”,缓存一致性机制会阻止同时修改由两个以上处理器缓存内存区域数据。

  • 一个处理器的缓存回写到内存会导致其他处理器的缓存无效

处理器使用嗅探技术来保证他的内部缓存、系统内存和其他处理器的缓存数据在总线上保存一致。

理解:volatile先”加锁”当前缓存写回内存,然后使其它使用该共享变量的内存无效,这样只能从内存中重新获取,这样就保证了可见性

synchronized的实现原理与应用

Java中的每一个对象都可以作为锁,具体表现为以下三种形式

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

锁的升级与对比

锁一共有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。

锁可以升级但是不能降级。

优点 缺点 使用场景
偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景
轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到锁竞争的线程,使用自旋会消耗CPU 追求响应时间,同步块执行速度非常快
重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步块执行速度较长

偏向锁初始化的流程

轻量级锁及膨胀流程图

原子操作的实现原理

  • 通过总线锁保证原子性
    所谓总线锁就是使用处理器提供的一个LOCK#信号,当一个处理器在总线上输出此信号时,其他处理器的请求将被阻塞住,那么该处理器就可以独占共享内存

  • 通过缓存锁定来保证原子性
    缓存锁定是指内存区域如果被缓存在处理器的缓存行中,并且在Lock操作期间被锁定,那么当它执行锁操作回写到内存时,处理器不在总线上声言LOCK#信号,而是修改内部的内存地址,并允许它的缓存一致性机制来保证操作的原子性,因为缓存一致性会阻止同时修改由两个以上处理器缓存的内存区域数据,当其他处理器回写已被锁定的缓存行的数据时,会使缓存行无效。

两种情况处理器不使用缓存锁定

  • 当操作数据不能缓存在处理器内部,或操作的数据跨多个缓存行时,则处理器会调用总线锁定
  • 有些处理器不支持缓存锁定。

Java中可以通过循环CAS的方式实现原子操作.

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

  • ABA问题,前面已经写过
  • 循环时间长开销大
  • 只能保证一个共享变量的原子操作(多个共享变量可以将其合并为一个共享变量来进行操作)