线程通信之Java同步与锁

线程相对于进程的特点,是可以共享全局变量和内存,使线程间通信变得很方便,但也带来了数据一致性的问题,即线程安全问题。在很多传统软件系统中,大部分程序都是串行化执行的,业务上就很少会有并发的场景,比如:审批系统里,用户按照工作流角色的分配,进行对文件的审批,一般不会出现并发情况,所以设计上就几乎不考虑并发。而现在的互联网系统中,场景往往存在很多是数据共享的,比如:多个用户抢票,多个用户下单等,每个用户操作就是一个线程,而操作的数据确是同一份,这就涉及到是否超卖等数据一致性的问题。

在解决线程通信问题时,有两个概念:同步和互斥。互斥,是指当多个数据操作同一共享数据时应该是,彼此互斥的,不允许同时进行修改;同步,说的直白一点就是,多个线程对同一个共享数据状态应该是同步的,对同一数据的操作,应该是有序的进行。所以,同步概念不仅包含对数据状态的同步,也包含多个相关联的线程之间的协调机制。

在Java线程通信时,主要是通过对象的访问来实现的,对象在单线程或并发时的表现是否都正常,也就是常常讨论的线程安全性。比如:HashMap不是线程安全的,ConcurrentHashMap是线程安全的。

在具体解决线程安全问题时,用什么方式呢?

原子性操作:多个线程环境下,一个线程的一组操作不被其他线程打断。Java中的原子操作底层是通过Unsafe类实现的,Unsafe类提供很多CAS(Compare and Swap)操作,通过先比较,再修改,如果比较时发现数据已经被修改过,则重试,直到修改成功,也叫乐观锁,是一种非阻塞式的算法;

:我们常说的锁也叫悲观锁,每次操作共享资源时,先获取锁,再操作资源,然后释放锁。如果已经被其他线程占有锁时,当前线程阻塞,直到其他线程释放锁时,再去竞争锁,才能操作资源。如:synchronize;

不可变性:因为具有不可变性,所以不存在线程安全问题。如:final;

线程封闭:使用线程独享的方式,其他线程访问不到,也不会涉及到线程安全问题。如:ThreadLocal;

synchronize

Java使用synchronize实现同步机制,也就是同步互斥锁。在执行synchronize修饰的内容时,要先获得锁,在执行内容。synchronize可以用来修饰对象方法、类方法和代码块:

public synchronized void method(){
    // 修饰对象方法
}

修饰对象方法时,锁对象就是当前对象实例。在同一个对象内的所有synchronize修饰的方法里,同一时刻只有一个方法可以被一个线程调用,其他线程调用其他方法会被阻塞。但同一个对象内的非synchronize修饰的方法不受影响。

public static synchronized void method(){
    // 修饰类方法
}

修饰类方法时,锁对象是当前类对象。同一个类的所有synchronize修饰的static方法里,同一时刻只能有一个方法被一个线程调用。由于类方法是全局共享的,所以synchronize修饰的static方法,也是全局唯一线程可以调用(不同于synchronize修饰的普通方法,不同对象可以同时调用同一方法,因为锁对象不一致)。

public void method(){
    synchronized(this){
        // 修饰代码块
    }
}

修饰代码块时,锁对象为括号中的对象。同步块内同一时刻也只能允许一个获得到锁对象的线程进入。

那么synchronize是怎么实现的呢?JVM虚拟机提供了Monitor(监视器,操作系统的管程)来实现同步机制。Monitor是基于C++实现的,由ObjectMonitor实现,数据结构如下:

每个Java对象都存在一个监视器,在没有受保护的代码和数据时,监视器不进行限制,只有当synchronize修饰的方法或者同步块被调用时,监视器(Monitor)才发挥同步机制作用,也就是先获得监视器锁,才能获得操作对象的权限。下面说一下监视器的工作流程:当多个线程访问同一段同步代码时,会先进入Entry Set(_EntryList)中,当某个线程获取到对象的monitor后进入The Owner区域,把monitor中的_owner设置为当前线程,_count计数器加1,也就是获得对象锁,若持有monitor的线程调用了wait()方法时,将释放掉持有的monitor,_owner变量恢复为null,_count减1,同时线程进入Wait Set(_WaitSet)等待被唤醒。若当前线程执行完毕,也将释放monitor锁,复位_owner,以便其他线程进入获得monitor锁。

当调用synchronize修饰的方法时(反编译代码会发现方法的修饰符上有ACC_SYNCHRONIZED标志),会先查看是否有ACC_SYNCHRONIZED标志,有的话需要先获得监视器锁;当调用synchronize修饰的代码块时,虚拟机通过monitorenter、monitorexit指令进行监视器进入和退出操作。

对象到底和监视器是怎么联系起来的呢,这里需要了解一下Java对象的内存结构:实例数据、填充数据、对象头。

实例数据就是Java代码中看到的属性和值;

填充数据就是Jvm要求java对象占有大小应该是8bit的倍数,所以需要补齐位数;

对象头包括Mark Word:存储对象自身的运行时数据,包括哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,还包括类指针:对象的类元数据,虚拟机通过这个指针确定该对象是哪个类的实例。

我们看到Mark Word中包含的重量级锁的指针就是指向的监视器对象。在JDK1.6版本之前,synchronize的实现主要就是重量级锁。为了实现高效并发,在JDK1.6版本进行了锁优化,synchronize的实现增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁。

适应性自旋:在某些场景下,线程遇到锁竞争时,不进入阻塞(不放弃CPU的执行时间),而是先自己做些无用操作,稍微等一下(实现方式是执行循环体)。

锁消除:针对同步块只能被一个线程访问的锁对象,编译器将同步块优化掉。

锁粗化:对连续的对同一个锁对象进行加锁,解锁操作进行优化编译的处理,扩大锁的范围(粗化)。

轻量级锁:采用CAS操作,试图将Mark Word中标识修改成锁记录,释放时候也是采用CAS方式进行重新修改Mark Word。如果存在锁竞争的话,加锁和释放都会失败,再升级到重量级锁进行重新操作。所以无竞争时,轻量级锁快,有竞争时,轻量级锁反而多了一个步骤。

偏向锁:当锁对象第一次被线程获取的时候,虚拟机把对象头的的标志位设为“01”,即偏向模式,同时CAS操作把这个线程的ID记录在Mark Word中,如果CAS成功,则每次这个线程进入这个锁相关的同步块时,虚拟机都不需要做任何同步操作。偏向锁就好像某个线程一直持有锁,当有竞争时候,才释放锁。

锁的升级:无锁 -> 偏向锁 -> 轻量锁 -> 重量锁

ReentrantLock

在JDK1.5版本,引入了ReentrantLock类来实现锁机制,与synchronize功能类似,叫可重入锁,用法更加灵活。并发场景的阻塞队列、并发集合、线程池执行器都是基于ReentrantLock实现的,下面我们看一下ReentrantLock实现:

从类图看出,ReentrantLock实现了Lock接口,Lock接口主要定义了锁对象的具体操作,包括获取锁(阻塞式、非阻塞式、超时方式)、释放锁、获取等待通知的条件:

  • lock():获取锁方法,如果获取锁后,从该方法返回;如果获取不到锁,线程进入阻塞(同进入synchronize修饰的代码一样,阻塞式操作)。
  • lockInterruptibly():可中断地获得锁,与lock()的不同点是,调用该方法后,可以响应中断(相对于synchronize增加了中断的响应)。
  • tryLock():尝试非阻塞方式获取锁,获取不到立即返回false;获取成功返回true。(可以灵活处理获取锁的过程)
  • tryLock(TImeUnit time):超时方式获取锁,在超时时间内获取锁,返回true;超过超时时间,返回false;超时时间内被中断;(注:线程获取锁失败可能阻塞也可能自旋)
  • unlock():释放锁。(与synchronize不同的是,开发人员需要手动调用锁释放的操作)
  • newCondition():获取等待通知的条件,使用条件可以实现像调用Object.wait()的阻塞,然后等待唤醒,在获取条件后,调用await()后,将释放锁。

那么ReentrantLock具体是怎么实现的锁的操作呢?查看ReentrantLock源代码,我们发现ReentrantLock对于Lock接口的实现非常简单:

public void lock() {
    sync.lock();
}

public void lockInterruptibly() throws InterruptedException {
    sync.acquireInterruptibly(1);
}

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

public boolean tryLock(long timeout, TimeUnit unit)
        throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}

public void unlock() {
    sync.release(1);
}

public Condition newCondition() {
    return sync.newCondition();
}

可以看到,ReentrantLock实现都是通过内部抽象类Sync及其提供的两个子类FairSync(公平同步器)、NonfairSync(非公平同步器)实现的。

Sync是Java的队列同步器——AQS(AbstractQueuedSynchronizer)的子类,AQS义了一套多线程访问共享资源的同步器框架,除了ReentrantLock基于AQS实现,Semaphore(信号量)、CountDownLatch(倒数门闩)也是基于AQS实现。下面我们分析一下AQS的实现原理:AQS提供的方法主要分为三类:同步队列、独占式获取与释放同步状态、共享式获取与释放同步状态。

同步队列

AQS中一个重要的数据结构Node,是构建同步队列的基本单位。AQS中也包含tail(尾节点)、head(头结点)、state(状态)。Node包含前驱、后继、获取同步失败的线程引用、节点属性(共享式-SHARED、独占式-EXCLUSIVE)、等待状态-waitStatus(CANCELLED-标识当前线程被取消【1】、SIGNAL-后继处于等待状态,当前线程释放锁,将通知后继节点【-1】、CONDITION-节点在Condition Queue上,等待被唤醒【-2】、PROPAGATE-共享模式下,获取锁和释放锁,进行传递式唤醒后续节点【-3】、INITIAL-初始状态,当前线程在Sync Queue上,等待获取锁【0】)等。

线程获取同步状态失败时,将加入同步队列的尾部。

同步队列遵循FIFO原则,首节点是获取同步状态成功的节点,首节点释放同步状态时,将唤醒后继节点,后继节点将自己设为首节点。

独占式获取与释放同步状态

获取锁

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

1、调用tryAcquire尝试性获取锁,获取成功直接返回;

2、tryAcquire获取失败,调用addWaiter,将当前线程封装成Node(独占模式),加入Sync Queue的队尾;

3、调用acquireQueued,进行自旋式获取锁,自旋过程中,如果前驱节点是头结点就尝试获得锁,否则告诉前驱节点,当前节点需要获取锁,然后进入阻塞,等待被唤醒;

4、如果需要响应中断,将抛出InterruptedException。

5、最后,将当前节点移除队列。

释放锁

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

1、调用tryRelease,修改Node状态

2、如果有需要唤醒的节点,将唤醒后继节点

独占式获取锁还包括acquireInterruptibly(可响应中断方式获取锁)、tryAcquireNanos(可超时方式获取锁)。主流程与acquire方式类似:

共享式获取与释放同步状态

获取锁

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
private void doAcquireShared(int arg) {
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            if (p == head) {
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

1、调用tryAcquire尝试性获取锁,获取成功直接返回;

2、tryAcquire获取失败,调用doAcquireShared方法共享式获取锁,将当前线程封装成Node(共享模式),加入Sync Queue的队尾;

3、然后,进行自旋式获取锁,自旋过程中,如果前驱节点是头结点就尝试获得锁,否则告诉前驱节点,当前节点需要获取锁,然后进入阻塞,等待被唤醒;

4、在获取到锁后,调用setHeadAndPropagate,对后续节点进行唤醒操作,以便同时多个线程并发的获取锁;(与独占式不同的是,获取锁也需要唤醒后继节点)

5、如果需要响应中断,将抛出InterruptedException;

6、最后,将当前节点移除队列;

释放锁

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}

1、调用tryReleaseShared,修改Node状态

2、如果有需要唤醒的节点,将唤醒所有后继节点(与独占式不同的是,需要唤醒多个后继节点)

共享式获取锁还包括acquireSharedInterruptibly(可响应中断方式获取锁)、tryAcquireSharedNanos(可超时方式获取锁)。主流程与acquireShared方式类似。

ReentrantLock主要使用AQS的独占式获取和释放同步状态。ReentrantLock具有可重入特点,所以叫可重入锁。可重入特性是通过TryAcquire方法实现的,当同一个线程,第二次调用lock时,就将当前状态加1,返回true,代表获取到了锁,然后unlock时,再减1,最后回到0状态。

另外,ReentrantLock还支持公平锁和非公平锁,公平锁就是按照FIFO队列进行分配锁,而非公平锁允许插队获取锁。

公平锁,在尝试获取锁时,调用hasQueuedPredecessors方法,判断是否有比自己等待锁时间更长的线程,如果有自己等一下,让等待时间更长的先获得锁,这样保证了公平性。

非公平锁,不仅在尝试获取锁时候,对自己前辈不谦让而且在刚获取锁时候,直接先尝试用CAS方式获得锁,也就实现了插队。

非公平锁在实际工作效率上比公平锁要好很多,为什么会出现这样的情况呢?

为了维持公平性,线程获得锁的顺序按照FIFO队列进行的,每次只有前一个线程释放锁之后,才唤醒下一个线程来获取锁,而恢复一个挂起的线程到这个线程拿到锁真正运行起来,通常有一定的时间间隔;而非公平锁,允许插队的行为,可能会在线程唤醒到拿到锁之间,已经完成了获取锁和释放锁操作,所以,竞争公平锁的线程等待的实际时间应该更长,所以非公平锁性能上更快。但非公平锁也会导致某些线程“饥饿”,就是迟迟获得不到锁。

synchronized 与 ReentrantLock 对比

注:线程的阻塞、唤醒是通过LockSupport的park、unpark实现的,底层是Unsafe的park、unpark方法(JNI方法)


  1. 《Java高并发编程详解-多线程与架构设计》
  2. 《Java并发编程实战》
  3. 《Java并发编程的艺术》
  4. 《深入理解Java虚拟机》
  5. https://blog.csdn.net/javazejian/article/details/72828483
  6. https://my.oschina.net/hmilyylimh/blog/1633117
  7. https://www.hollischuang.com/archives/1883
  8. 《Java核心技术 卷I》

本文分享自微信公众号 - BanzClub(banz-club)

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2018-12-13

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券