java锁事追问集

316 阅读14分钟

CAS

1. 一句话总结

  • compare and swap 比较并交换;在更新值的时候检查下值有没有发生变化,如果没有发生变化则更新
  • 通过底层硬件获得原子性语句的支持。
    哪条原子性语句呢?
  • cpu的 lock cmpxchgl

2. ABA问题

  • 如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。这就是CAS的ABA问题
  • 解决思路:使用版本号
    • 在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A。 目前在JDK的atomic包里提供了一个类AtomicStampedReference来解决ABA问题

3. 是否要自旋 (比如 while(true){})

CAS的实现方式不同,

  • 如果自旋就会导致本线程,本CPU不停的空转,浪费时间片;
    • 解决方案可以参照jdk8之后的 jdk#LongAdder设计
  • 如果不自旋,就是超级乐观的锁设计,只适合线程冲突小的系统,因为不自旋的话,compare失败的线程就结束了(除非你再设计一套唤醒机制, 比如下面的AQS)

4.java的实现

java中使用Unsafe类调用c++代码

public final boolean compareAndSet(int expect, int update) {   
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

这个本地方法在openjdk中依次调用的c++代码为:

  • unsafe.cpp
  • atomic.cpp
  • atomicwindowsx86.inline.hpp 最终走到下面的代码, 如果是多核cpu,就锁内存总线,保证原子性
/ alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }

AQS

是AbstractQueuedSynchronizer类的缩写,抽象队列同步器

1. java中的常见应用

  • CountDownLatch
  • ReenTrantLock
  • Semphare

2. AQS的原理,用一句话概括

如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中等待被唤醒(防止过度自旋,浪费cpu)。

3. 和synchronized的区别

4. 为什么要有一个队列

将获取锁失败的线程放进队列等待,仍有被唤醒并获取锁的机会,这个机会和策略由CLH队列实现;

CLH:Craig、Landin and Hagersten(3个外国人)队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

5. CLH队列第一个节点为什么是虚节点

目的 :∵节点入队不是原子操作,首节点是虚节点就是为了解决高并发场景下的线程安全问题
在第一个线程设置state的时候,CLH队列为空; 在第二个线程设置的时候,CAS失败,加入CLH队列的时候,首节点Node不是线程2,而是AQS无参构造器生成的节点,第二个节点才是线程2

我们关注AQS的hasQueuedPredecessors()方法,是公平锁加锁时判断等待队列中是否存在有效节点的方法。如果返回False,说明当前线程可以争取共享资源;如果返回True,说明队列中存在有效节点,当前线程必须加入到等待队列中

// java.util.concurrent.locks.ReentrantLock

public final boolean hasQueuedPredecessors() {
	// The correctness of this depends on head being initialized
	// before tail and on head.next being accurate if the current
	// thread is first in queue.
	Node t = tail; // Read fields in reverse initialization order
	Node h = head;
	Node s;
	return h != t && ((s = h.next) == null || s.thread != Thread.currentThread());
}
  • 当h != t时: 如果(s = h.next) == null,等待队列正在有线程进行初始化,但只是进行到了Tail指向Head,没有将Head指向Tail,此时队列中有元素,需要返回True(这块具体见下边代码分析)。
  • 如果(s = h.next) != null,说明此时队列中至少有一个有效节点。
  • 如果此时s.thread == Thread.currentThread(),说明等待队列的第一个有效节点中的线程与当前线程相同,那么当前线程是可以获取资源的;
  • 如果s.thread != Thread.currentThread(),说明等待队列的第一个有效节点线程与当前线程不同,当前线程必须加入进等待队列 如果之后还有线程竞争,就在队列中继续排队就行。

6. AQS核心方法

  • 加锁 acquire()
// java.util.concurrent.locks.AbstractQueuedSynchronizer

public final void acquire(int arg) {
	if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
		selfInterrupt();
}

可以看到tryAcquire是尝试拿锁,如果获取失败,才会加入队列中
而tryAcquire(), 发现AQS内只是简单实现,实际使用时需要自己Override, 比如ReenTrantLock就自己重写了

// java.util.concurrent.locks.AbstractQueuedSynchronizer

protected boolean tryAcquire(int arg) {
	throw new UnsupportedOperationException();
}
  • 进入队列
// java.util.concurrent.locks.AbstractQueuedSynchronizer

final boolean acquireQueued(final Node node, int arg) {
	boolean failed = true;
	try {
		// 标记等待过程中是否中断过
		boolean interrupted = false;
		for (;;) {
			// 获取当前节点的前驱节点
			final Node p = node.predecessor();
			// 如果p是头结点,说明当前节点在真实数据队列的首部,就尝试获取锁(别忘了头结点是虚节点)
			if (p == head && tryAcquire(arg)) {
				setHead(node);
				p.next = null; // help GC
				failed = false;
				return interrupted;
			}
			
			if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
				interrupted = true;
		}
	} finally {
		if (failed)
			cancelAcquire(node);
	}
}

可以看到,final修饰,不能被覆写。上述代码主要的思路是:

  • 开始自旋,不断尝试获取锁
  • 如果获取到锁就结束,返回 ; 否则就休息一会再尝试获取锁
  • finally代码块中,在获取锁最终失败后,会将节点状态置为CANCELLED
  • 不断校验当前节点的前驱节点,如果状态是CANCELLED, 就剔除这个节点,将当前节点往前移动
  • 如果前驱节点状态是SIGNAL(人家前驱节点有资格获取锁),当前节点就休息(park, 阻塞)会吧
    • 借用LockSupport的静态方法park()
    • 返回当前线程的应该中断与否,上层方法会执行Thread.interrupted()进行中断 ps
  • 中断和阻塞的区别
    • Java中断机制是一种协作机制,也就是说通过中断并不能直接终止另一个线程,而需要被中断的线程自己处理中断。这好比是家里的父母叮嘱在外的子女要注意身体,但子女是否注意身体,怎么注意身体则完全取决于自己
    • 中断是调用Thread.interrupt(),设置一个状态而已,true表示已中断,false表示未中断。设置线程中断不影响线程的继续执行,但是线程设置中断后,线程内调用了wait、jion、sleep方法中的一种, 立马抛出一个 InterruptedException,且中断标志被清除,重新设置为false。
    • 阻塞才能使线程真正地停下来,需要设置线程为阻塞,使用LockSupport.park(),具体原理此处不表。

7. 【总结】关键节点

CLH队列中Node节点的状态

  • 何时出队列
  • 如何出队列
  • 何时阻塞
  • 何时中断
  • 何时唤醒 总的来说,一个线程获取锁失败了,被放入等待队列,acquireQueued会把放入队列中的线程不断去获取锁,直到获取成功或者不再需要获取(中断)。

synchronized

1. 用法

  • 用于非静态方法
  • 用于静态方法
  • 用于锁对象
  • 用于代码块

public class useSynchronized {
    //1.修饰普通方法, 锁是当前类的某一对象,即this
    public synchronized void normalMethod(){
        
    }
    
    //2.修饰静态方法,锁是useSynchronized.class
    public synchronized static void staticMethod(){
        
    }
    
    //没有同步的方法
    public void methodWithoutSync(){
        
    }

    public void methodA(){
        synchronized (useSynchronized.class){
            //3.同步代码块,锁是类对象
        }
    }
    
    public void methodB(A a){
        synchronized (a){
            //4.同步代码块
        }
    }

2.一句话总结

轻量级锁使用CAS, 底层时lock cmpxchg指令

重量级锁用的管程monitor,底层使用c++ ObjectMonitor, 是对象私有的

3. 锁升级

其中, 无锁、偏向、轻量级锁都是在用户态;重量级锁需要向内核态申请资源

升级过程

4.锁升级条件

偏向锁升级轻量级锁--条件:

  • 只要有别的线程竞争

轻量级锁升级重量级锁--条件

  • 【曾经是】竞争超过10次 or 等待线程超过 1/2 CPU核数 or 耗时过长 or 有wait操作
  • 【现在是】自适应的,jvm自动升级

轻量级锁发生竞争时:

  • 各个线程使用 CAS 操作,对对象的 markword进行比较--交换,尝试写上自己线程id

5.预习知识

  • markWord

6.基本原理

  • 偏向锁
    • 在对象头markword中,写了第一个线程的thread Id,寄希望与没有人改它,所以连CAS都不用做
  • 在偏向锁到轻量级锁升级的过程中,
    • 使用CAS,不停比较存储的mardWord和本线程的id
    • CAS, 底层是lock cmpxchg
  • 轻量级锁(自旋锁)
    • 尝试使用CAS去 给markword写入线程独有的lockRecord起始地址 ,如果失败就一直自旋
  • 重量级锁
    • 用的 monitor管程

      • synchornized 方法,JVM使用ACC_SYNCHRONIZED标识来实现。即JVM通过在方法访问标识符(flags)中加入ACC_SYNCHRONIZED来实现同步功能, 根本上也是monitorenter和monitorexit。

      • synchornized代码块,JVM使用monitorenter和monitorexit两个指令实现同步。即JVM为代码块的前后真正生成了两个字节码指令来实现同步功能的。

    • 底层使用的 c++ ObjectMonitor类来实现上面的原语,锁住的对象,在锁升级到重量级时,自动生成一个ObjectMonitor对象,作为内置锁,用来让各个线程争抢。

    • 无论上面两个指令中的哪一个,均是JVM通过调用操作系统的互斥原语mutex来实现,被阻塞的线程会被挂起、等待重新调度,会导致“用户态和内核态”两个态之间来回切换,对性能有较大影响。

巧了,wait(), nitify(), notifyall()正是互斥量模型的原语,以及java object类的方法

【为什么这些方法都在Object类(而不是Thread类)呢?

  • 这就是该类所私有的ObjectMonitor对象的c++方法】

ReentrantLock

1. 原理

CAS + AQS

// java.util.concurrent.locks.ReentrantLock#NonfairSync

// 非公平锁
static final class NonfairSync extends Sync {
	...
	final void lock() {
		if (compareAndSetState(0, 1))
			setExclusiveOwnerThread(Thread.currentThread());
		else
			acquire(1);
		}
  ...
}

2. 和ReenTrantReadWriteLock的区别

它和后者都分别实现了AQS,彼此之间没有继承或实现的关系。

ReenTrantLock是互斥锁(独占锁), 也就是一次只能有一个线程持有锁

ReenTrantReadWriteLock是共享锁, 一个资源能够被多个读线程访问,或者被一个写线程访问,但是不能同时存在读写线程。
也就是说读写锁使用的场合是一个共享资源被大量读取操作,而只有少量的写操作(修改数据)

3. 加锁和解锁流程

Atomic类

1. 一句话总结

  • atomic类是通过自旋+CAS操作+volatile变量实现的。

2. jdk8 的LongAdder 和 AtomicLong相比,优化了哪里?

高并发下N多线程同时去操作一个变量会造成大量线程CAS失败,然后处于自旋状态,导致严重浪费CPU资源,降低了并发性。既然AtomicLong性能问题是由于过多线程同时去竞争同一个变量的更新而降低的,那么如果把一个变量分解为多个变量,让同样多的线程去竞争多个资源,这就是LongAdder的原理。

LongAdder则是内部维护一个Cells数组,每个Cell里面有一个初始值为0的long型变量,在同等并发量的情况下,争夺单个变量的线程会减少,这是变相的减少了争夺共享资源的并发量,另外多个线程在争夺同一个原子变量时候,如果失败并不是自旋CAS重试,而是尝试获取其他原子变量的锁,最后当获取当前值时候是把所有变量的值累加后再加上base的值返回的。

ThreadLocal

1.简介

对比其他解决线程安全的方案,更轻量级,开销更小,且更简单。

2.如何使用

static ThreadLocal<A>  threadLocal= new ThreadLocal<>();

threadLocal.set(1996);
int temp = threadLocal.get();
threadLocal.remove();

3、一句话总结

  • 每个Thread对象,其ThreadLocalMap都不一样, 即线程私有;
  • 通过弱引用 来避免内存泄漏

4.原理

每个线程都有1个ThreadLocalMap变量, key是ThreadLocal对象, value是Entry对象,其属性只有Object value

# java.lang.Thread
/*
* InheritableThreadLocal values pertaining to this thread. This map is
* maintained by the InheritableThreadLocal class.
*/
ThreadLocal.ThreadLocalMap threadlocal = null

而ThreadLocalMap本身是ThreadLocal的静态内部类, 其内部又维护了另一个静态内部类Entry的实例数组


static class ThreadLocalMap {

        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

        /**
         * The initial capacity -- MUST be a power of two.
         */
        private static final int INITIAL_CAPACITY = 16;

        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;
        
        //省略下面的
}

通过ThreadLocal.get()时,ThreadLocal转交给 当前线程的ThreadLocalMap去get()

get()的源码如下


 /**
     * Returns the value in the current thread's copy of this
     * thread-local variable.  If the variable has no value for the
     * current thread, it is first initialized to the value returned
     * by an invocation of the {@link #initialValue} method.
     *
     * @return the current thread's value of this thread-local
     */
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

5.为什么使用弱引用WeakReference

目的:防止内存泄漏

6.为什么要用hashCode()? hash方法怎么设计的?

  • 因为实现Map的时候,使用简单的数组Entry[] 来存储节点, (而不是TreeMap等方式实现)
    • 如果要根据key来获取Entry 及value, 使用hash比较常见。
/**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

上面源码中的magic number,其实是斐波那契数列的魔数,每次hash得到的结果

下面就枚举了,size分别为16,32, 64的情况下,每次hash得到的index

16:0 7 14 5 12 3 10 1 8 15 6 13 4 11 2 9

32:0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25

64:0 7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57

观察上面的index,我们会发现:
使用这样的hash方法,在数组填满前,不会冲突。

7.hash冲突的时候怎么解决?

  • 冲突时,采用开放地址法,寻找合适的插入位置。index++往后追加
    • 这种方式在冲突频繁的时候,查询性能就差一些,所以尽量要避免冲突。 使用斐波那契hash算法就是尽量避免冲突

volatile

1.简介

轻量级地解决线程同步问题

2.作用

  • 【保证线程可见性】
  • 【禁止指令重排序】
  • 【不保证原子性】

3.原理

  • 一句话原理 voaltile关键字 --> jvm 字节码【ACC_VOLATILE】 --> c++【voaltile关键字 】--> 【lock addl】 实现

  • 实现可见性 通过Lock前缀实现可见性,lock指令下一条指令, 写完缓存后,当前CPU回写主存, 并立即通知其他线程重新读缓存行

  • 实现有序性

JMM层面的“内存屏障”: LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。 StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。 LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。 StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

  • JVM的实现会在volatile读写前后均加上内存屏障,在一定程度上保证有序性。如下所示:
LoadLoadBarrier  
volatile 读操作  
LoadStoreBarrier 

StoreStoreBarrier  
volatile 写操作  
StoreLoadBarrier  

但上面的内存屏障只是jvm的实现要求, Hot spot实现还是汇编的lock addl指令实现的

addl把寄存器的值加0,相当于一个空操作,关键还是想要addl前面的[lock] (之所以用addl,不用空操作专用指令nop,是因为lock前缀不允许配合nop指令使用)

参考资料

  1. 美团技术博客-<<从ReentrantLock的实现看AQS的原理及应用>>
  2. 开源博客-<<Java多线程(十)之ReentrantReadWriteLock深入分析>>
  3. <<深入分析Synchronized原理>>