前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >线程通信之Java同步与锁

线程通信之Java同步与锁

作者头像
搬砖俱乐部
发布2019-06-15 17:33:20
7760
发布2019-06-15 17:33:20
举报
文章被收录于专栏:BanzClubBanzClub

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

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

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

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

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

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

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

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

synchronize

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

代码语言:javascript
复制
public synchronized void method(){
    // 修饰对象方法
}

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

代码语言:javascript
复制
public static synchronized void method(){
    // 修饰类方法
}

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

代码语言:javascript
复制
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接口的实现非常简单:

代码语言:javascript
复制
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原则,首节点是获取同步状态成功的节点,首节点释放同步状态时,将唤醒后继节点,后继节点将自己设为首节点。

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

获取锁

代码语言:javascript
复制
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、最后,将当前节点移除队列。

释放锁

代码语言:javascript
复制
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方式类似:

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

获取锁

代码语言:javascript
复制
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、最后,将当前节点移除队列;

释放锁

代码语言:javascript
复制
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》
本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2018-12-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 BanzClub 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • synchronize
  • ReentrantLock
  • 同步队列
  • 独占式获取与释放同步状态
  • 共享式获取与释放同步状态
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档