架构师集合之锁+synchronized原理篇

锁的分类

公平锁/非公平锁

  • 公平锁是指多个线程按照申请锁的顺序来获取锁。

  • 非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于Java ReentrantLock而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。

对于Synchronized而言,也是一种非公平锁。由于其并不像ReentrantLock是通过AQS的来实现线程调度,所以并没有任何办法使其变成公平锁。

乐观锁/悲观锁

  • 悲观锁,每次去请求数据的时候,都认为数据会被抢占更新(悲观的想法);所以每次操作数据时都要先加上锁,其他线程修改数据时就要等待获取锁。适用于写多读少的场景,synchronized就是一种悲观锁

  • 乐观锁,在请求数据时,觉得无人抢占修改。等真正更新数据时,才判断此期间别人有没有修改过(预先读出一个版本号或者更新时间戳,更新时判断是否变化,没变则期间无人修改);和悲观锁不同的是,期间数据允许其他线程修改。

乐观锁在Java中的使用,是无锁编程,常常采用的是CAS算法,典型的例子就是原子类,通过CAS自旋实现原子操作的更新。

独享锁/共享锁

  • 独享锁是指该锁一次只能被一个线程所持有。

  • 共享锁是指该锁可被多个线程所持有。

对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

对于Synchronized而言,当然是独享锁。

互斥锁/读写锁

上面讲的独享锁/共享锁就是一种广义的说法,互斥锁/读写锁就是具体的实现。

  • 互斥锁在Java中的具体实现就是ReentrantLock

  • 读写锁在Java中的具体实现就是ReadWriteLock

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁。

对于Java ReentrantLock而言, 他的名字就可以看出是一个可重入锁,其名字是Re entrant Lock重新进入锁。

对于Synchronized而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

自旋锁

一句话,魔力转转圈。当尝试给资源加锁却被其他线程先锁定时,不是阻塞等待而是循环再次加锁,在锁常被短暂持有的场景下,线程阻塞挂起导致CPU上下文频繁切换,这可用自旋锁解决;但自旋期间它占用CPU空转,因此不适用长时间持有锁的场景

分段锁

分段锁其实是一种锁的设计,并不是具体的一种锁,对于ConcurrentHashMap而言,其并发的实现就是通过分段锁的形式来实现高效的并发操作。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对Synchronized。在Java 5通过引入锁升级的机制来实现高效Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

  • 偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

  • 轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

  • 重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

对象结构

Java对象结构中的对象头描述部分是实现锁机制的关键,实际上在HotSpot JVM 虚拟机的工作中将对象结构分为三大块区域:对象头(Header)、实例数据(Instance Data)和对齐填充区域(可能存在)。如下图所示:
对象结构

对齐填充区域(Padding):对齐填充区域并不是必须存在的,它只是起到占位作用——这是因为HotSpot JVM 虚拟机要求被管理的对象的大小都是8字节的整数倍。那么在某些情况下,就需要填充区域对不足的对象区域进行填充(随后的实例中会有)

实例数据:这个区域当然就是描述真实的对象数据。这个区域包括了对象中的所有字段属性信息,它们可能是某个其它对象的地址引用,也可能是基础数据的数据值。

对象头(Header):对象头是本节内容会讨论重点讨论的部分。为了便于讨论,我们讨论32位JDK版本和32位操作系统下它的内部结构(64位JDK版本和64位操作系统的情况类似,只不过各主要结构都变成了32位的长度)。视情况它又可能分为2-3个子结构:

  • 数组长度(只有数组形式的对象会有这个区域):数组对象的这个区域表达了数组长度。

  • klass 这是一个指针区域,这个指针区域指向元数据区中(JDK1.8)该对象所代表的类描述,这样JVM才知道这个对象是哪一个类的实例

  • markword 区域是该对象关键的运行时数据,主要就是这个对象当前锁机制的记录信息。

注意:整个对象头的描述结构的长度并不是固定不变的,首先在32位操作系统和64位操作系统中就有结构长度上的差异。另外在启用的对象指针压缩和没有启用对象指针压缩的情况下,整个对象头的长度也不一样:64位平台下,原生对象头大小为16字节,压缩后为12字节。

synchronized底层原理

代码使用synchronized加锁,在编译之后的字节码是怎样的呢

public class Test {
    public static void main(String[] args){
        synchronized(Test.class){
            System.out.println("hello");
        }
    }
}

截取部分字节码,如下:

4: monitorenter
5: getstatic    #9    // Field java/lang/System.out:Ljava/io/PrintStream; 
8: ldc           #15   // String hello
10: invokevirtual #17  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
13: aload_1
14: monitorexit

字节码出现了4: monitorenter和14: monitorexit两个指令;字面理解就是监视进入,监视退出。可以理解为代码块执行前的加锁,和退出同步时的解锁。这里省略了一个monitorexit,后面会有个遇到异常抛弃的monitorexit,就是防止遇到异常,系统不会自动释放锁。

执行monitorenter指令时,线程会为锁对象关联一个ObjectMonitor对象

objectMonitor.cpp
  ObjectMonitor() {
    _header       = NULL;
    _count        = 0;   \\用来记录该线程获取锁的次数
    _waiters      = 0,
    _recursions   = 0;    \\锁的重入次数
    _object       = NULL;
    _owner        = NULL;  \\当前持有ObjectMonitor的线程
    _WaitSet      = NULL;  \\wait()方法调用后的线程等待队列
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ; \\阻塞等待队列
    FreeNext      = NULL ;
    _EntryList    = NULL ; \\synchronized 进来线程的排队队列
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;  \\自旋计算
    OwnerIsThread = 0 ;
  }

每个线程都有两个ObjectMonitor对象列表,分别为free和used列表,如果当前free列表为空,线程将向全局global list请求分配ObjectMonitor。

ObjectMonitor的owner、WaitSet、Cxq、EntryList这几个属性比较关键。WaitSet、Cxq、EntryList的队列元素是包装线程后的对象-ObjectWaiter;而获取owner的线程,既为获得锁的线程。

monitorenter对应的执行方法:

void ATTR ObjectMonitor::enter(TRAPS)  {
    ...
    //获取锁:cmpxchg_ptr原子操作,尝试将_owner替换为自己,并返回旧值
    cur = Atomic::cmpxchg_ptr (Self, &_owner, NULL) ;
    ...
    // 重复获取锁,次数加1,返回
    if (cur == Self) {
        _recursions ++ ;
        return ;
    }
    //首次获取锁情况处理
    if (Self->is_lock_owned ((address)cur)) {
        assert (_recursions == 0, "internal state error");
        _recursions = 1 ;
        _owner = Self ;
        OwnerIsThread = 1 ;
        return ;
    }
    ...
    //尝试自旋获取锁
    if (Knob_SpinEarly && TrySpin (Self) > 0) {
    ...
    // 空循环执行 ObjectMonitor::EnterI 方法等待锁的释放
    for (;;) {
     jt->set_suspend_equivalent();
     // cleared by handle_special_suspend_equivalent_condition()
     // or java_suspend_self()

     EnterI (THREAD) ;

monitorexit对应的执行方法**void ATTR ObjectMonitor::exit(TRAPS)…**代码太长,就不贴了。主要是recursions减1、count减少1或者如果线程不再持有owner(非重入加锁)则设置owner为null,退锁的持有状态,并唤醒Cxq队列的线程

总结

线程遇到synchronized同步时,先会进入EntryList队列中,然后尝试把owner变量设置为当前线程,同时monitor中的计数器count加1,即获得对象锁。否则通过尝试自旋一定次数加锁(默认是十次),失败则进入Cxq队列阻塞等待.

synchronized修饰方法原理也是类似的。只不过没用monitor指令,而是使用ACC_SYNCHRONIZED标识方法的同步。

synchronized是可重入,非公平锁,因为entryList的线程会先自旋尝试加锁,而不是加入cxq排队等待,不公平。

Object的wait和notify方法原理

wait,notify必须是持有当前对象锁Monitor的线程才能调用 (对象锁代指ObjectMonitor/Monitor,锁对象代指Object),上面有说到,当在sychronized中锁对象Object调用wait时会加入waitSet队列,WaitSet的元素对象就是ObjectWaiter

class ObjectWaiter : public StackObj {
 public:
  enum TStates { TS_UNDEF, TS_READY, TS_RUN, TS_WAIT, TS_ENTER, TS_CXQ } ;
  enum Sorted  { PREPEND, APPEND, SORTED } ;
  ObjectWaiter * volatile _next;
  ObjectWaiter * volatile _prev;
  Thread*       _thread;
  ParkEvent *   _event;
  volatile int  _notified ;
  volatile TStates TState ;
  Sorted        _Sorted ;           // List placement disposition
  bool          _active ;           // Contention monitoring is enabled
 public:
  ObjectWaiter(Thread* thread);
  void wait_reenter_begin(ObjectMonitor *mon);
  void wait_reenter_end(ObjectMonitor *mon);
};

调用对象锁的wait()方法时,线程会被封装成ObjectWaiter,最后使用park方法挂起

//objectMonitor.cpp
void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS){
    ...
    //线程封装成 ObjectWaiter对象
    ObjectWaiter node(Self);
    node.TState = ObjectWaiter::TS_WAIT ;
    ...
    //一系列判断操作,当线程确实加入WaitSet时,则使用park方法挂起
    if (node._notified == 0) {
        if (millis <= 0) {
            Self->_ParkEvent->park () ;
        } else {
            ret = Self->_ParkEvent->park (millis) ;
        }
    }

而当对象锁使用notify()时
如果waitSet为空,则直接返回
waitSet不为空从waitSet获取一个ObjectWaiter,然后根据不同的Policy加入到EntryList或通过Atomic::cmpxchg_ptr指令自旋操作加入cxq队列或者直接unpark唤醒。

void ObjectMonitor::notify(TRAPS){
    CHECK_OWNER();
    //waitSet为空,则直接返回
    if (_WaitSet == NULL) {
        TEVENT (Empty-Notify) ;
        return ;
    }
    ...
    //通过DequeueWaiter获取_WaitSet列表中的第一个ObjectWaiter
    Thread::SpinAcquire (&_WaitSetLock, "WaitSet - notify") ;
    ObjectWaiter * iterator = DequeueWaiter() ;
    if (iterator != NULL) {
    ....
    if (Policy == 2) {      // prepend to cxq
         // prepend to cxq
         if (List == NULL) {
             iterator->_next = iterator->_prev = NULL ;
             _EntryList = iterator ;
         } else {
            iterator->TState = ObjectWaiter::TS_CXQ ;
            for (;;) {
                ObjectWaiter * Front = _cxq ;
                iterator->_next = Front ;
                if (Atomic::cmpxchg_ptr (iterator, &_cxq, Front) == Front) {
                    break ;
                }
            }
         }
     }

Object的notifyAll方法则对应voidObjectMonitor::notifyAll(TRAPS),流程和notify类似。不过会通过for循环取出WaitSet的ObjectWaiter节点,再依次唤醒所有线程

jvm对synchronized的优化

MarkWord 32位的存储结构:

锁状态25bit4bit1bit2bit
23bit2bit是否是偏向锁锁标志位
无锁对象的HashCode对象分代年龄001
偏向锁线程IDEpoch: 偏向锁时间戳对象分代年龄101
轻量级锁指向栈帧中锁记录的指针00
重量级锁指向互斥量(重量级锁)的指针10
GC标志11

偏向锁

  • 偏向锁:一个对象刚开始实例化的时候,没有任何线程来访问它的时候。它是可偏向的,意味着,它现在认为只可能有一个线程来访问它,所以当第一个线程来访问它的时候,它会偏向这个线程,此时,对象持有偏向锁。偏向第一个线程,这个线程在修改对象头成为偏向锁的时候使用CAS操作,并将对象头中的ThreadID改成自己的ID,之后再次访问这个对象时,只需要对比ID,不需要再使用CAS在进行操作。偏向锁在Java 6和Java 7里是默认启用的。可以通过JVM参数关闭偏向锁:-XX:-UseBiasedLocking。

轻量级锁

  • 轻量级锁:一旦有第二个线程访问这个对象,因为偏向锁不会主动释放,所以第二个线程可以看到对象时偏向状态,这时表明在这个对象上已经存在竞争了。检查原来持有该对象锁的线程是否依然存活,如果挂了,则可以将对象变为无锁状态,然后重新偏向新的线程。如果原来的线程依然存活,则马上执行那个线程的操作栈,检查该对象的使用情况,如果仍然需要持有偏向锁,则偏向锁升级为轻量级锁,(偏向锁就是这个时候升级为轻量级锁的),此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁;如果不存在使用了,则可以将对象回复成无锁状态,然后重新偏向。

重量级锁

  • 重量级锁: 轻量级锁认为竞争存在,但是竞争的程度很轻,一般两个线程对于同一个锁的操作都会错开,或者说稍微等待一下(自旋),另一个线程就会释放锁。 但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁膨胀为重量级锁,重量级锁使除了拥有锁的线程以外的线程都阻塞,防止CPU空转。使用-XX:+UseHeavyMonitors禁用其他锁。

其他锁优化

锁消除

JVM即时编译器( JIT )在运行时, 对一些代码上要求同步但被检测到不可能存在共享数据竞争的锁进行消除, 这是基于逃逸分析的技术。

  • 逃逸分析:主要用于分析确定对象是否有可能逃逸出当前线程,被其他线程访问到。如果对象不存在逃逸,则可以将其在虚拟机栈中分配,以便减小垃圾回收的压力。

  • server -XX:+DoEscapeAnalysis -XX:+EliminateLocks开启优化,其中+DoEscapeAnalysis表示开启逃逸分析,+EliminateLocks表示锁消除。

锁粗化

如果一系列连续操作都对同一个对象加锁和解锁, 那么即便没有线程争用,频繁地进行互斥操作也会导致不必要的性能损耗.JVM 探测到这种情况就会把加锁同步的范围粗化到整个操作序列的外部.

自适应自旋锁

自旋锁:等待锁的线程并不挂起,而是先执行一个空循环(自旋),看看持有锁的线程是否很快就会释放锁。在JDK1.6中默认开启。1.7开始内置无法改变。

  • 优点: 避免了线程切换的开销,在锁被占有的时间很短时效果很好

  • 缺点: 如果锁被占用的时间很长,自旋会白白消耗处理器资源,带来性能上的浪费

自适应自旋锁:DK1.6引入自适应的自旋锁,如果在同一个锁对象上, 自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行,则认为此次自旋也很可能成功,进而允许它自旋等待相对更长的时间.反之,如果对某个锁自旋很少成功获得过锁,则以后获取这个锁时很可能省略掉自旋过程.

CAS

CAS全称Compare And Set(或Compare And Swap),CAS包含三个操作数:内存位置(V)、原值(A)、新值(B)。简单来说CAS操作就是一个虚拟机实现的原子操作,这个原子操作的功能就是将旧值(A)替换为新值(B),如果旧值(A)未被改变,则替换成功,如果旧值(A)已经被改变则替换失败。CAS是实现自旋锁的基础。

既然用锁或 synchronized 关键字可以实现原子操作,那么为什么还要用 CAS 呢,因为加锁或使用 synchronized 关键字带来的性能损耗较大,而用 CAS 可以实现乐观锁,它实际上是直接利用了 CPU 层面的指令,所以性能很高。

在jdk是有提供同步版的CAS解决方案,其中使用了UnSafe.java的底层方法。

    public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);

    public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

    public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);

我们再来看看本地方法,Unsafe.cpp中的compareAndSwapInt

//unsafe.cpp
UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

在Linux的x86,Atomic::cmpxchg方法的实现如下

/**
    1 __asm__表示汇编的开始;
    2 volatile表示禁止编译器优化;//禁止指令重排
    3 LOCK_IF_MP是个内联函数,
      根据当前系统是否为多核处理器,
      决定是否为cmpxchg指令添加lock前缀 //内存屏障
*/
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value) {
  int mp = os::is_MP();
  __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                    : "=a" (exchange_value)
                    : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                    : "cc", "memory");
  return exchange_value;
}

jdk提供的CAS机制,在汇编层级,会禁止变量两侧的指令优化,然后使用cmpxchg指令比较并更新变量值(原子性),如果是多核则使用lock锁定(缓存锁、MESI)

CAS同步操作的问题

ABA问题
  • 线程X准备将变量的值从A改为B,然而这期间线程Y将变量的值从A改为C,然后再改为A;最后线程X检测变量值是A,并置换为B。但实际上,A已经不再是原来的A了

解决方法,是把变量定为唯一类型。值可以加上版本号,或者时间戳。如加上版本号,线程Y的修改变为A1->B2->A3,此时线程X再更新则可以判断出A1不等于A3

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

只保证一个共享变量的原子操作,对多个共享变量同步时,循环CAS是无法保证操作的原子

CAS适合场景

CAS 适合简单对象的操作,比如布尔值、整型值等;
CAS 适合冲突较少的情况,如果太多线程在同时自旋,那么长时间循环会导致 CPU 开销很大;

基于volatile + CAS 实现同步锁的原理

先说说实现锁的要素

  • 同步代码块同一时刻只能有一个线程能执行

  • 加锁操作要happens-before同步代码块里的操作,而代码块里的操作要happens-before解锁操作

  • 同步代码块结束后相对其他线程其修改的变量是可见的 (内存可见性)

要素1:可以利用CAS的原子性来实现,任意时刻只有一个线程能成功操作变量

要素2:使用volatile修饰状态变量,禁止指令重排

要素3:还是用volatile,volatile变量写指令前后会插入内存屏障

//伪代码
volatile state = 0 ;   // 0-无锁 1-加锁;volatile禁止指令重排,加入内存屏障
...
if(cas(state, 0 , 1)){ // 1 加锁成功,只有一个线程能成功加锁
    ...                // 2 同步代码块
    cas(state, 1, 0);  // 3 解锁时2的操作具有可见性
}

LockSupport

LockSupport是基本的线程阻塞的原语,通过park和unpark来实现线程的阻塞和唤醒。LockSupport的每个使用它的线程都与一个许可(permit)有关,permit是一个0,1的开关,默认是0,unpark会将permit变为1。park会消耗permit,变为0,同是park马上返回。如果再调用一次park,则会阻塞再这里,直到unpark将permit变为1。 permit最多只有1个,重复调用unpark不会累计。

void park()
void unpark(Thread thread)

LockSupport和CAS操作是java并发包中很多控制机制的基础,都是通过UNSAFE来实现的。Unsafe.park,unpark操作时,会调用当前线程的变量parker代理执行。Parker代码

JavaThread* thread=JavaThread::thread_from_jni_environment(env);
...
thread->parker()->park(isAbsolute != 0, time);
class PlatformParker : public CHeapObj {
  protected:
    //互斥变量类型
    pthread_mutex_t _mutex [1] ; 
   //条件变量类型
    pthread_cond_t  _cond  [1] ;
    ...
}

class Parker : public os::PlatformParker {  
private:  
  volatile int _counter ;  
  ...  
public:  
  void park(bool isAbsolute, jlong time);  
  void unpark();  
  ...  
}

在Linux系统下,用的POSIX线程库pthread中的mutex(互斥量),condition来实现线程的挂起、唤醒。

unpark和park执行顺序不同时,_counter 和_cond 的状态变化如下:

  • 先park后unpark; park:counter值不变,但会设置一个cond; unpark:counter先变为1,检查cond存在,counter减为0。

  • 先unpark后park;unpark:counter变为1,cond不存在。park:counter减1。

  • 先多次unpark;counter也只设置为为1

LockSupport是JDK中用来实现线程阻塞和唤醒的工具。使用它可以在任何场合使线程阻塞,可以指定任何线程进行唤醒,并且不用担心阻塞和唤醒操作的顺序,但要注意连续多次唤醒的效果和一次唤醒是一样的。

JDK并发包下的锁和其他同步工具的底层实现中大量使用了LockSupport进行线程的阻塞和唤醒,掌握它的用法和原理可以让我们更好的理解锁和其它同步工具的底层实现。

LockSupport.park和Object.wait区别

  • 两种方式都有具有挂起的线程的能力

  • 线程在Object.wait之后必须等到Object.notify才能唤醒

  • LockSupport可以先unpark线程,等线程执行LockSupport.park是不会挂起的,可以继续执行

  • 需要注意的是就算线程多次unpark;也只能让线程第一次park是不会挂起

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小明程序猿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值