锁的知识点总结,悲观锁、乐观锁等等

锁的种类有很多,我一一总结介绍,并将我参考的文章贴出来。
之前看过一遍,过了几天就忘了,哈哈,这东西还是得常看,加深理解。
我把锁对比着来总结,也许容易理解。

悲观锁与乐观锁

1、悲观锁:

总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。Java中synchronizedReentrantLock等独占锁就是悲观锁思想的实现。

2、乐观锁

总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号机制CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库提供的类似于write_condition机制,其实都是提供的乐观锁。在Java中java.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS实现的。

3、两种锁的使用场景

乐观锁适用于写比较少的情况下(多读场景),冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。

悲观锁适合多写的场景,由于多写的情况经常会产生冲突,导致上层应用不断Retry,会降低性能。

4、乐观锁常见的两种实现方式

a.版本号机制

一般是在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改时,version值会加一。当线程A要更新数据值时,在读取数据的同时也会读取version值,在提交更新时,若刚才读取到的version值为当前数据库中的version值相等时才更新,否则重试更新操作,直到更新成功。

核心SQL代码:

update table set x=x+1, version=version+1 where id=#{id} and version=#{version};  

b.CAS算法

compare and swap(比较与交换),是一种有名的无锁算法。无锁编程,即不使用锁的情况下实现多线程之间的变量同步,也就是在没有线程被阻塞的情况下实现变量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS算法涉及到三个操作数

  • 需要读写的内存值 V
  • 旧的预期值 A
  • 拟写入的新值 B

当且仅当 V 的值等于 A时,CAS通过原子方式用新值B来更新V的值,否则不会执行任何操作(比较和替换是一个原子操作)。一般情况下是一个自旋操作,即不断的重试。

5、乐观锁的缺点

a. ABA 问题https://www.cnblogs.com/549294286/p/3766717.html)

如果一个变量V初次读取的时候是A值,并且在准备赋值的时候检查到它仍然是A值,那我们就能说明它的值没有被其他线程修改过了吗?很明显是不能的,因为在这段时间它的值可能被改为其他值,然后又改回A,那CAS操作就会误认为它从来没有被修改过。这个问题被称为CAS操作的 “ABA”问题。

JDK 1.5 以后的 AtomicStampedReference 类就提供了此种能力,其中的 compareAndSet 方法就是首先检查当前引用是否等于预期引用,并且当前标志是否等于预期标志,如果全部相等,则以原子方式将该引用和该标志的值设置为给定的更新值。

b. 循环时间长开销大

自旋CAS(也就是不成功就一直循环执行直到成功)如果长时间不成功,会给CPU带来非常大的执行开销。 如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

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

CAS 只对单个共享变量有效,当操作涉及跨多个共享变量时 CAS 无效。但是从 JDK 1.5开始,提供了AtomicReference类来保证引用对象之间的原子性,你可以把多个变量放在一个对象里来进行 CAS 操作.所以我们可以使用锁或者利用AtomicReference类把多个共享变量合并成一个共享变量来操作。

互斥锁与自旋锁

1、互斥锁:

线程同步能够保证多个线程安全访问竞争资源,最简单的同步机制是引入互斥锁。互斥锁为资源引入一个状态:锁定/非锁定。某个线程要更改共享数据时,先将其锁定,此时资源的状态为“锁定”,其他线程不能更改;直到该线程释放资源,将资源的状态变成“非锁定”,其他的线程才能再次锁定该资源。互斥锁保证了每次只有一个线程进行写入操作,从而保证了多线程情况下数据的正确性。

即:同一时刻只能有一个线程访问对象。

其实我觉得互斥锁和悲观锁很像,但是我的理解是,悲观锁关注的是数据库的操作,通过数据库的锁的机制才保证数据修改的原子性。而互斥锁,是保证了一个共享对象(例如全局变量)修改时的原子性。

2、自旋锁:

自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环

获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting

它是为实现保护共享资源而提出一种锁机制。其实,自旋锁与互斥锁比较类似,它们都是为了解决对某项资源的互斥使用。无论是互斥锁,还是自旋锁,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,”自旋”一词就是因此而得名。

3、自旋锁的实现:

public class SpinLock {
    private AtomicReference<Thread> cas = new AtomicReference<Thread>();
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) {
            // DO nothing
        }
    }
    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock()方法利用的CAS,当第一个线程A获取锁的时候,能够成功获取到,不会进入while循环,如果此时线程A没有释放锁,另一个线程B又来获取锁,此时由于不满足CAS,所以就会进入while循环,不断判断是否满足CAS,直到A线程调用unlock方法释放了该锁。

解释:

compareAndSet(A,B),如果需要读写的值不等于A,则返回false;
compareAndSet(null,current),如果需要读写的值不是null(null即无锁状态),那么返回false;否则用current(当前线程)获取锁。

4、自旋锁的缺点:

  • 如果某个线程持有锁的时间过长,就会导致其它等待获取锁的线程进入循环等待,消耗CPU。使用不当会造成CPU使用率极高
  • 上面Java实现的自旋锁不是公平的,即无法满足等待时间最长的线程优先获取锁。不公平的锁就会存在“线程饥饿”问题。

5、自旋锁的优点:

  • 自旋锁不会使线程状态发生切换,一直处于用户态,即线程一直都是active的;不会使线程进入阻塞状态,减少了不必要的上下文切换,执行速度快
  • 非自旋锁在获取不到锁的时候会进入阻塞状态,从而进入内核态,当获取到锁的时候需要从内核态恢复,需要线程上下文切换。 (线程被阻塞后便进入内核(Linux)调度状态,这个会导致系统在用户态与内核态之间来回切换,严重影响锁的性能)

6、总结:

  • 自旋锁与互斥锁都是为了实现保护资源共享的机制
  • 无论是自旋锁还是互斥锁,在任意时刻,都最多只能有一个保持者
  • 获取互斥锁的线程,如果锁已经被占用,则该线程将进入睡眠状态获取自旋锁的线程则不会睡眠,而是一直循环等待锁释放

TicketLock、CLHLock、MCSLock(均为自旋锁变种)

1、TicketLock:

TicketLock主要解决的是公平性的问题。

思路:每当有线程获取锁的时候,就给该线程分配一个递增的id,我们称之为排队号,同时,锁对应一个服务号,每当有线程释放锁,服务号就会递增,此时如果服务号与某个线程排队号一致,那么该线程就获得锁,由于排队号是递增的,所以就保证了最先请求获取锁的线程可以最先获取到锁,就实现了公平性。

可以想象成银行办理业务排队,排队的每一个顾客都代表一个需要请求锁的线程,而银行服务窗口表示锁,每当有窗口服务完成就把自己的服务号加一,此时在排队的所有顾客中,只有自己的排队号与服务号一致的才可以得到服务。

线程获取锁之后,将它的排队号返回,等该线程释放锁的时候,需要将该排队号传入。但这样是有风险的,因为这个排队号是可以被修改的,一旦排队号被不小心修改了,那么锁将不能被正确释放。因此我们将每个线程的排队号放到了ThreadLocal中与实现connection的数据库连接池一样)。

实现代码:

public class TicketLockV2 {
    /**
     * 服务号
     */
    private AtomicInteger serviceNum = new AtomicInteger();
    /**
     * 排队号
     */
    private AtomicInteger ticketNum = new AtomicInteger();
    /**
     * 新增一个ThreadLocal,用于存储每个线程的排队号
     */
    private ThreadLocal<Integer> ticketNumHolder = new ThreadLocal<Integer>();
    public void lock() {
        int currentTicketNum = ticketNum.incrementAndGet();
        // 获取锁的时候,将当前线程的排队号保存起来
        ticketNumHolder.set(currentTicketNum);
        while (currentTicketNum != serviceNum.get()) {
            // Do nothing
        }
    }
    public void unlock() {
        // 释放锁,从ThreadLocal中获取当前线程的排队号
        Integer currentTickNum = ticketNumHolder.get();
        serviceNum.compareAndSet(currentTickNum, currentTickNum + 1);
    }
}

TicketLock存在的问题:

多处理器系统上,每个进程/线程占用的处理器都在读写同一个变量serviceNum ,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能。

而MCSLock和CLHLock就是解决这个问题的。

2、CLHLock:

CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋,获得锁。

代码如下:

import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
/**
 * CLH的发明人是:Craig,Landin and Hagersten。
 * 代码来源:http://ifeve.com/java_lock_see2/
 */
public class CLHLock {
    /**
     * 定义一个节点,默认的lock状态为true
     */
    public static class CLHNode {
        private volatile boolean isLocked = true;
    }
    /**
     * 尾部节点,只用一个节点即可
     */
    private volatile CLHNode tail;
    private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<CLHNode>();
    private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(CLHLock.class, CLHNode.class,
            "tail");
    public void lock() {
        // 新建节点并将节点与当前线程保存起来
        CLHNode node = new CLHNode();
        LOCAL.set(node);
        // 将新建的节点设置为尾部节点,并返回旧的节点(原子操作),这里旧的节点实际上就是当前节点的前驱节点
        CLHNode preNode = UPDATER.getAndSet(this, node);
        if (preNode != null) {
            // 前驱节点不为null表示当锁被其他线程占用,通过不断轮询判断前驱节点的锁标志位等待前驱节点释放锁
            while (preNode.isLocked) {
            }
            preNode = null;
            LOCAL.set(node);
        }
        // 如果不存在前驱节点,表示该锁没有被其他线程占用,则当前线程获得锁
    }
    public void unlock() {
        // 获取当前线程对应的节点
        CLHNode node = LOCAL.get();
        // 如果tail节点等于node,则将tail节点更新为null,同时将node的lock状态职位false,表示当前线程释放了锁
        if (!UPDATER.compareAndSet(this, node, null)) {
            node.isLocked = false;
        }
        node = null;
    }
}

3、MCSLock:

MCSLock则是对本地变量的节点进行循环,查看当前节点锁的状态,当前节点A的锁状态默认为True,则自旋,只有当前一节点发现后面还有节点才将A的锁状态设置为False,解除自旋。(仔细看源码)

/**
 * MCS:发明人名字John Mellor-Crummey和Michael Scott
 * 代码来源:http://ifeve.com/java_lock_see2/
 */
public class MCSLock {
    /**
     * 节点,记录当前节点的锁状态以及后驱节点
     */
    public static class MCSNode {
        volatile MCSNode next;
        volatile boolean isLocked = true;
    }
    private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<MCSNode>();
    // 队列
    @SuppressWarnings("unused")
    private volatile MCSNode queue;
    // queue更新器
    private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class,
            "queue");
    public void lock() {
        // 创建节点并保存到ThreadLocal中
        MCSNode currentNode = new MCSNode();
        NODE.set(currentNode);
        // 将queue设置为当前节点,并且返回之前的节点
        MCSNode preNode = UPDATER.getAndSet(this, currentNode);
        if (preNode != null) {
            // 如果之前节点不为null,表示锁已经被其他线程持有
            preNode.next = currentNode;
            // 循环判断,直到当前节点的锁标志位为false
            while (currentNode.isLocked) {
            }
        }
    }
    public void unlock() {
        MCSNode currentNode = NODE.get();
        // next为null表示没有正在等待获取锁的线程
        if (currentNode.next == null) {
            // 更新状态并设置queue为null
            if (UPDATER.compareAndSet(this, currentNode, null)) {
                // 如果成功了,表示queue==currentNode,即当前节点后面没有节点了
                return;
            } else {
                // 如果不成功,表示queue!=currentNode,即当前节点后面多了一个节点,表示有线程在等待
                // 如果当前节点的后续节点为null,则需要等待其不为null(参考加锁方法)
                while (currentNode.next == null) {
                }
            }
        } else {
            // 如果不为null,表示有线程在等待获取锁,此时将等待线程对应的节点锁状态更新为false,同时将当前线程的后继节点设为null
            currentNode.next.isLocked = false;
            currentNode.next = null;
        }
    }
}

4、总结:

  • CLHLock和MCSLock都是基于链表,不同的是CLHLock是基于隐式链表,没有真正的后续节点属性,MCSLock是显示链表,有一个指向后续节点的属性。
  • 将获取锁的线程状态借助节点(node)保存,每个线程都有一份独立的节点,这样就解决了TicketLock多处理器缓存同步的问题。
  • TicketLock:采用类似银行排号叫好的方式实现自旋锁的公平性,但是由于不停的读取serviceNum,每次读写操作都必须在多个处理器缓存之间进行缓存同步,这会导致繁重的系统总线和内存的流量,大大降低系统整体的性能
  • CLHLock和MCSLock通过链表的方式避免了减少了处理器缓存同步(我觉得是因通过本地变量才减少的处理器缓存同步),极大的提高了性能,区别在于CLHLock是通过轮询其前驱节点的状态,而MCS则是查看当前节点的锁状态
  • CLHLock在NUMA架构下使用会存在问题。在没有cache的NUMA系统架构中,由于CLHLock是在当前节点的前一个节点上自旋,NUMA架构中处理器访问本地内存的速度高于通过网络访问其他节点的内存,所以CLHLock在NUMA架构上不是最优的自旋锁。

加锁会带来哪些性能问题?如何解决?

加锁其实是用性能弥补速度,很多情况下用户追求的是并发,如果为每个用户的操作都加锁,那其他用户在上一个用户操作时只能等待。而且锁处理不当,会产生“饥饿”问题,导致用户永远无法获得需要的锁。参考上面TicketLock,这是一种公平锁。

解决方法:
1.减小锁的持有时间:把锁加在需要锁的部分,不要范围太大。

2.减小锁的粒度:如ConcurrentMap,是把Map分成Segment,再给每个Segment加锁,默认是16段。所谓减小锁粒度,就是指缩小锁定对象的范围,从而减小锁冲突的可能性,进而提高系统性能。

3.用读写分离锁来代替独占锁:使用读写锁是减小锁粒度的一种特殊方法,上面的Segment是对数据的划分,而读写锁就是对功能点的划分。如ReentrantReadWriteLock,读读不互斥,读写互斥,写写互斥。

4.锁分离:LinkedBlockingQueue阻塞队列(之后会在博客中单独介绍),put和take在队头队尾有两把不同的锁。由于读只操作队头,而写只操作队尾,这里巧妙地采用了两把锁,对put和offer采用putLock,对take和poll采用takeLock,即为写锁和读锁,在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

5.锁粗化:虚拟机在遇到一连串地对同一锁不断进行请求和释放的操作时,便会把所有的锁操作整合成对锁的一次请求,从而减小对锁的请求同步次数,这个操作叫锁粗化。

6.JVM优化锁做出的努力
a.锁偏向:当前只有一个线程获得锁时,会记录该线程ID,加上偏向锁,下次竞争时判断线程ID是否指向当前线程,如果是,直接获取锁。
b.轻量级锁
c.自旋锁
d.锁消除:在JIT编译时,通过对上下文扫描,将所有完全没有竞争的资源的锁消除。比如单线程使用Vector。

阻塞队列

LinkedBlockingQueue、ArrayBlockingQueue、LinkedBlockingDeque

LinkedBlockingQueue(线程安全、有界阻塞)

链表结构,有一个head头指针和last尾指针,继承AbstractQueue。如图:
Alt
此队列按FIFO(先进先出)排序元素。

链接队列的吞吐量通常要高于基于数组的对列(ArrayBlockingQueue),但是在大多数并发应用程序中,其可预知的性能要低

如果构造一个LinkedBlockingQueue对象,而没有指定其容量大小,LinkedBlockingQueue会默认一个类似无限大小的容量(Integer.MAX_VALUE),这样的话,如果生产者的速度一旦大于消费者的速度,也许还没有等到队列满阻塞产生,系统内存就有可能已被消耗殆尽了。

添加元素的步骤(put):

① 判断元素是否为null,若是,则抛出异常,否则,进入步骤②

② 获取存元素锁,并上锁,如果当前线程被中断,则抛出异常,否则,进入步骤③

③ 判断当前队列中的元素个数是否已经达到指定容量,若是,则在notFull条件上进行等待,否则,进入步骤④

④ 将新生结点入队列,更新队列元素个数,若元素个数小于指定容量,则唤醒在notFull条件上等待的线程,表示可以继续存放元素。进入步骤⑤

⑤ 释放锁,判断结点入队列之前的元素个数是否为0,若是,则唤醒在notEmpty条件上等待的线程(表示队列中没有元素,取元素线程被阻塞了)。

取出元素的步骤(take):

① 获取取元素锁,并上锁,如果当前线程被中断,则抛出异常,否则,进入步骤②

② 判断当前队列中的元素个数是否为0,若是,则在notEmpty条件上进行等待,否则,进入步骤③

③ 出队列,更新队列元素个数,若元素个数大于1,则唤醒在notEmpty条件上等待的线程,表示可以继续取元素。进入步骤④

④ 释放锁,判断结点出队列之前的元素个数是否为指定容量,若是,则唤醒在notFull条件上等待的线程(表示队列已满,存元素线程被阻塞了)。

remove方法,同时对putLock和takeLock加锁,因为remove方法删除的数据的位置不确定,为了避免造成并非安全问题,所以需要对2个锁同时加锁,成功返回true失败返回false。

poll方法也比较简单,如果队列没有数据就返回null,如果队列有数据,那么就取出来,如果队列还有数据那么唤醒等待在条件对象notEmpty上的消费线程。

take方法是一个可阻塞可中断的移除方法。

offer(E e, long timeout, TimeUnit unit),在指定时间内阻塞添加的方法,超时就结束。

offer(E e),第一件事是判断队列是否满,满了就直接释放锁,没满就将节点封装成Node入队,然后再次判断队列添加完成后是否已满,不满就继续唤醒等到在条件对象notFull上的添加线程。第二件事是,判断是否需要唤醒等到在notEmpty条件对象上的消费线程。。

其他函数不再一一介绍,参考:https://blog.csdn.net/sinat_36553913/article/details/79533606

ArrayBlockingQueue(线程安全、有界阻塞)

参考:https://blog.csdn.net/javazejian/article/details/77410889
ArrayBlockingQueue 是一个用数组实现的有界阻塞队列,其内部按先进先出的原则对元素进行排序,其中put方法和take方法为添加和删除的阻塞方法,offer和add都是无阻塞添加。

poll(),获取并删除队列头元素。

remove(Object o)方法的删除过程相对复杂些,因为该方法并不是直接从队列头部删除元素。首先线程先获取锁,再一步判断队列count>0,这点是保证并发情况下删除操作安全执行。接着获取下一个要添加源的索引putIndex以及takeIndex索引 ,作为后续循环的结束判断,因为只要putIndex与takeIndex不相等就说明队列没有结束。然后通过while循环找到要删除的元素索引,执行removeAt(i)方法删除,在removeAt(i)方法中实际上做了两件事,一是首先判断队列头部元素是否为删除元素,如果是直接删除,并唤醒添加线程,二是如果要删除的元素并不是队列头元素,那么执行循环操作,从要删除元素的索引removeIndex之后的元素都往前移动一个位置,那么要删除的元素就被removeIndex之后的元素替换,从而也就完成了删除操作。

take()方法,是一个阻塞方法,直接获取队列头元素并删除。

总结LinkedBlockingQueue和ArrayBlockingQueue:

1.队列大小不同,ArrayBlockingQueue是有界的初始化必须指定大小,LinkedBlockingQueue可以是有界也可以是无界的(Integer.MAX_VALUE)。

2.数据存储容器不同,ArrayBlockingQueue采用数组作为数据存储容器,LinkedBlockingQueue采用以Node节点作为连接对象的链表。

3.由于ArrayBlockingQueue采用的是数组的存储容器,因此在插入或删除元素时不会产生或销毁任何额外的对象实例,而LinkedBlockingQueue则会生成一个额外的Node对象。这可能在长时间内需要高效并发地处理大批量数据的时,对于GC可能存在较大影响。

4.两者的实现队列添加或移除的锁不一样,ArrayBlockingQueue实现的队列中的锁是没有分离的,即添加操作和移除操作采用的同一个ReenterLock锁,而LinkedBlockingQueue实现的队列中的锁是分离的,其添加采用的是putLock,移除采用的则是takeLock,这样能大大提高队列的吞吐量,也意味着在高并发的情况下生产者和消费者可以并行地操作队列中的数据,以此来提高整个队列的并发性能。

5.ArrayBlockingQueue内部完成添加操作后,会直接唤醒消费线程对元素进行获取,这是因为ArrayBlockingQueue只用了一个ReenterLock同时对添加线程和消费线程进行控制,这样如果在添加完成后再次唤醒添加线程的话,消费线程可能永远无法执行。而对于LinkedBlockingQueue来说就不一样了,其内部对添加线程和消费线程分别使用了各自的ReenterLock锁对并发进行控制,也就是说添加线程和消费线程是不会互斥的,所以添加锁只要管好自己的添加线程即可,添加线程自己直接唤醒自己的其他添加线程,如果没有等待的添加线程,直接结束了。如果有就直到队列元素已满才结束挂起(指的是put方法会挂起,offer方法不会挂起)。

非阻塞队列

ConcurrentLinkedQueue(无界、线程安全)

参考:https://blog.csdn.net/qq_38293564/article/details/80798310

非阻塞的实现方式则可以使用循环CAS的方式来实现。

默认情况下head节点存储的元素为空,tail节点等于head节点。

入队操作(tail节点不一定是队尾节点)
将当前节点插入到队尾节点的下一节点。
更新tail节点,如果tail节点的下一节点不为空,则将入队节点设为tail节点;如果tail节点的下一节点为空,则直接入队。

从源代码角度来看整个入队过程主要做两件事情:
第一是定位出尾节点
第二是使用CAS算法能将入队节点设置成尾节点的next节点,如不成功则重试。

tail节点不一定为尾节点的设计意图

在JDK 1.7的实现中,doug lea使用hops变量来控制并减少tail节点的更新频率,并不是每次节点入队后都将 tail节点更新成尾节点,而是当tail节点和尾节点的距离大于等于常量HOPS的值(默认等于1)时才更新tail节点,tail和尾节点的距离越长使用CAS更新tail节点的次数就会越少,但是距离越长带来的负面效果就是每次入队时定位尾节点的时间就越长,因为循环体需要多循环一次来定位出尾节点,但是这样仍然能提高入队的效率,因为从本质上来看它通过增加对volatile变量的读操作来减少了对volatile变量的写操作,而对volatile变量的写操作开销要远远大于读操作,所以入队效率会有所提升。

出队操作
并不是每次出队时都更新head节点,当head节点里有元素时,直接弹出head节点里的元素,而不会更新head节点。只有当head节点里没有元素时,出队操作才会更新head节点。采用这种方式也是为了减少使用CAS更新head节点的消耗,从而提高出队效率。

总结

ConcurrentLinkedQueue 的非阻塞算法实现可概括为下面 5 点:

  • 使用 CAS 原子指令来处理对数据的并发访问,这是非阻塞算法得以实现的基础。
  • head/tail 并非总是指向队列的头 / 尾节点,也就是说允许队列处于不一致状态。 这个特性把入队 / 出队时,原本需要一起原子化执行的两个步骤分离开来,从而缩小了入队 / 出队时需要原子化更新值的范围到唯一变量。这是非阻塞算法得以实现的关键。
  • 由于队列有时会处于不一致状态。为此,ConcurrentLinkedQueue 使用三个不变式来维护非阻塞算法的正确性。
  • 以批处理方式来更新 head/tail,从整体上减少入队 / 出队操作的开销。
  • 为了有利于垃圾收集,队列使用特有的 head 更新机制;为了确保从已删除节点向后遍历,可到达所有的非删除节点,队列使用了特有的向后推进策略。

参考资料:
面试必备之深入理解自旋锁

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值