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

2020/11/25 23:11 下午 posted in  Java并发编程的艺术

volatile的应用

volatile是轻量级的synchronized,在并发编程中2个作用:

  1. 保证了共享变量的可见性
  2. 禁止指令重排序

可见性的意思是当一个线程修改了一个共享变量时,另一个线程能读到修改的这个值。

volatile的定义与实现

定义如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排它锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明为volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

有volatile变量修饰的共享变量进行写操作的时候会多出汇编代码,lock。

lock前缀的指令在多核处理器下引发了两件事情:

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

原理:
缓存一致性协议 MESI
总线嗅探

禁止指令重排序


https://tech.meituan.com/2014/09/23/java-memory-reordering.html

volatile的使用优化

缓存行是缓存中可以分配的最小存储单位,缓存行64字节。

采用追加字节的方式,使生成的对象占用64字节。

以下有个优化的例子,

A类通过声明7个没有用的long类型变量,加上一个有用的long类型变量x,总共8个long类型变量,共64字节,或者是14个引用类型,引用类型占4字节,加一个long类型变量x,也是64字节。

然后启动两个线程,每个线程对x累加10000000次,x被声明为volatile,每次修改都会对其他线程可见,

把追加的字节删除之后在测试,用时差异比较明显。

追加字节后测试时间大概60几毫秒,
不追加字节测试时间大概200多毫秒。

展开查看
public class CacheLineTest {

	static class A{
		/**
		 * 采用追加字节的方式,生成对象A占用64字节,正好是缓存行的容量
		 * 一个long类型8字节。
		 * private long a1,a2,a3,a4,a5,a6,a7;
		 * 一个引用类型4字节。
		 * private Object a1,a2,a3,a4,a5,a6,a7,a8,a9,aa,ab,ac,ad,ae;
		 */
		private long a1,a2,a3,a4,a5,a6,a7;
		private volatile long x = 0;

		public long getX() {
			return x;
		}

		public void setX(long x) {
			this.x = x;
		}
	}

	public static long test() throws InterruptedException {
		A a1 = new A();
		A a2 = new A();

		Thread t1 = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < 10000000; i++) {
					a1.setX(i);
				}
			}
		});


		Thread t2 = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < 10000000; i++) {
					a2.setX(i);
				}
			}
		});

		long start = System.currentTimeMillis();

		t1.start();
		t2.start();

		t1.join();
		t2.join();

		long end = System.currentTimeMillis();

		long diff = end - start;
		System.out.println("花费时间: " + diff);
		return diff;
	}


	public static void main(String[] args) throws InterruptedException {
		test();
	}

}

synchronized的实现原理与应用

Java中的每一个对象都可以被作为锁。具体表现为以下3种形式:

  1. 对于普通同步方法,锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是synchronized括号里配置的对象。

代码块同步是使用monitorenter和monitorexit指令实现的。

monitorenter是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处。任何对象都有一个monitor与之关联,当一个monitor被持有后,它将处于锁定状态。

Java对象头

synchronized用的锁是存在Java对象头里的。

Java对象头里的Mark Word里默认存储对象的 HashCode、分代年龄和锁标记位。

64位虚拟机下,Mark Word是64bit大小,其存储结构为:

锁升级与对比

Java SE 1.6 为了减少获得锁和释放锁带来的性能消耗,引入了 “偏向锁” 和 “轻量级锁”。

锁一共有4中状态,级别从低到高依次是:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态,这几个状态随着竞争情况逐渐升级。

锁可以升级但不能降级。目的是为了提高获得锁和释放锁的效率。

偏向锁

Java虚拟机的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让这种线程获得锁的代价更低而引入了偏向锁。

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储偏向的线程ID,以后该线程在进入和退出同步块时不需要进行加锁和解锁,只需要简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。

偏向锁的撤销

当出现竞争的情况,偏向锁会撤销,并升级为轻量级锁。

偏向锁的撤销需要等待全局安全点(safe point,通常会STW),会首先暂停拥有 偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,占中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁加锁就是将锁的对象头中的线程ID指向自己,使这个锁偏向于自己。
偏向锁的撤销就是将锁对象的对象头中的线程ID设置为空。

轻量级锁

加锁

线程在执行同步块之前,JVM会现在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

解锁

轻量级解锁时,会使用原子的CAS操作将当前线程的栈帧中的对象头替换回到锁对象的对象头,如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

自旋会消耗CPU,所以一旦锁升级为重量级锁就不会再恢复到轻量级锁状态。

锁的优点与缺点

原子操作的实现原理

总线锁

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

总线锁开销比较大。

缓存锁

缓存一致性协议,MESI。

Java如何实现原子操作

自旋CAS

JVM中的CAS操作利用了处理器提供的CMPXCHG指令实现的。

CAS操作带来的问题:

  1. ABA问题

    当一个值A,变成B,又变为A,这时候使用CAS进行检查时会发现它的值没有发生变化,但实际上是发生变化的。

    ABA问题的解决思路就是使用版本号或者时间戳。

  2. 循环时间长开销大

  3. 只能保证一个共享变量的原子操作

锁机制

偏向锁、轻量级锁和互斥锁。

除了偏向锁,JVM实现锁的方式都用了自旋CAS。