《多处理器编程艺术》-链表:锁的作用

最近在阅读《多处理器编程艺术》一书,掌握了很多Java多线程的底层知识,现在就做一下书中链表-锁的作用一章的总结。
 为了节约你的时间,本文主要内容如下:

  • 带锁的链表队列
  • 细粒度同步
  • 乐观同步
  • 惰性同步
  • 非阻塞同步

粗粒度同步

 所谓粗粒度同步其实很简单,就是在List的add,remove,contains函数的开始就直接使用Lock加锁,然后在函数结尾释放。
add函数的代码如下所示,函数的主体就是链表的遍历添加逻辑,只不过在开始和结束进行了锁的获取和释放。

    private Node head;
    private Lock lock = new ReentrantLock();
    public boolean add(T item) {
        Node pred, curr;
        int key = item.hashCode();
        lock.lock();
        try {
            pred = head;
            curr = pred.next;
            while(curr.key < key) {
                pred = curr;
                curr = pred.next;
            }
            if (key == curr.key) {
                return false;
            } else {
                Node node = new Node(item);
                node.next = curr;
                pred.next = node;
                return true;
            }

        } finally {
            lock.unlock();
        }
    }

 大家看到这里就会想到,这不就是类似于Hashtable的实现方式吗?把可能出现多线程问题的函数都用一个重入锁锁住。但是这个方法的缺点很明显,如果竞争激烈的话,对链表的操作效率会很低,因为add,remove,contains三个函数都需要获取锁,也都需要等待锁的释放。至于如何优化,我们可以一步一步往下看

细粒度同步

我们可以通过锁定单个节点而不是整个链表来提高并发。给每个节点增加一个Lock变量以及相关的lock()和unlock()函数,当线程遍历链表的时候,若它是第一个访问节点的线程,则锁住被访问的节点,在随后的某个时刻释放锁。这种细粒度的锁机制允许并发线程以流水线的方式遍历链表。
 使用这种方式来遍历链表,必须同时获取两个相邻节点的锁,通过“交叉手”的方式来获取锁:除了初始的head哨兵节点外,只有在已经获取pred的锁时,才能获取curr的锁。

//每个Node对象中都有一个Lock对象,可以进行lock()和unlock()操作
public boolean add(T item) {
        int key = item.hashCode();
        head.lock();
        Node pred = head;
        try {
            Node curr = pred.next;
            curr.lock();

            try {
                while (curr.key < key) {
                    pred.unlock();
                    pred = curr;
                    curr = pred.next;
                    curr.lock();
                }

                if (curr.key == key) {
                    return false;
                }
                Node newNode = new Node(item);
                newNode.next = curr;
                pred.next = newNode;
                return true;
            } finally {
                curr.unlock();
            }

        } finally {
            pred.unlock();
        }
    }

乐观同步

 虽然细粒度锁是对单一粒度锁的一种改进,但它仍然出现很长的获取锁和释放锁的序列。而且,访问链表中不同部分的线程仍然可能相互阻塞。例如,一个正在删除链表中第二个元素的线程将会阻塞所有试图查找后继节点的线程。
 减少同步代价的一种方式就是乐观:不需要获取锁就可以查找,对找到的节点进行加锁,然后确认锁住的节点是正确的;如果一个同步冲突导致节点被错误的锁定,则释放这些锁重新开始

    public boolean add(T item) {
        int key = item.hashCode();

        while (true) { //如果不成功,就进行重试
            Node pred = head;
            Node curr = pred.next;
            while (curr.key < key) {
                pred = curr;
                curr = pred.next;
            }
            //找到目标相关的pred和curr之后再将二者锁住
            pred.lock();
            curr.lock();
            try {
                //锁住二者之后再进行判断,是否存在并发冲突
                if (validate(pred, curr)) {
                    //如果不存在,那么就直接进行正常操作
                    if (curr.key == key) {
                        return false;
                    } else {
                        Node node = new Node(item);
                        node.next = curr;
                        pred.next = node;
                    }
                }
            } finally {
                pred.unlock();
                curr.unlock();
            }
        }
    }
    public boolean validate(Node pred, Node curr) {
        //从队列头开始查找pred和curr,判断是否存在并发冲突
        Node node = head;
        while (node.key <= pred.key) {
            if (node == pred) {
                return pred.next == curr;
            }
            node = node.next;
        }
        return false;
    }

 由于不再使用能保护并发修改的锁,所以每个方法调用都可能遍历那些已经被删除的节点,所以在进行添加,删除获取判断是否存在的之前必须再次进行验证。

惰性同步

 当不用锁遍历两次链表的代价比使用锁遍历一次链表的代价小很多时,乐观同步的实现效果非常好。但是这种算法的缺点之一就是contains()方法在遍历时需要锁,这一点并不令人满意,其原因在于对contains()的调用要比其他方法的调用频繁得多。
使用惰性同步的方法,使得contains()调用是无等待的,同时add()和remove()方法即使在被阻塞的情况下也只需要遍历一次链表
对每个节点增加一个布尔类型的marked域,用于说明该节点是否在节点集合中。现在,遍历不再需要锁定目标结点,也没有必须通过重新遍历整个链表来验证结点是否可达。所有未被标记的节点必然是可达的

//add方法和乐观同步的方法一致,只有检验方法做了修改。
//只需要检测节点的marked变量就可以,并且查看pred的next是否还是指向curr,需要注意的是marked变量一定是voliate的。
private boolean validate(Node pred, Node curr) {
        return !pred.marked && !curr.marked && pred.next == curr;
}

 惰性同步的优点之一就是能够将类似于设置一个flag这样的逻辑操作与类似于删除结点的链接这种对结构的物理改变分开。通常情况下,延迟操作可以是批量处理方式进行,且在某个方便的时候再懒惰地进行处理,从而降低了对结构进行物理修改的整体破裂性。惰性同步的主要缺点是add()和remove()调用是阻塞的:如果一个线程延迟,那么其他线程也将延迟。

非阻塞同步

 使用惰性同步的思维是非常有益处的。我们可以进一步将add(),remove()和contains()这三个方法都变成非阻塞的。前两个方法是无锁的,最后一个方法是无等待的。我们无法直接使用compareAndSet()来改变next域来实现,因为这样会出现问题。但是我们可以将结点的next域和marked域看作是单个的原子单位:当marked域为true时,对next域的任何修改都将失败。
 我们可以使用AtomicMarkableReference<T>对象将指向类型T的对象引用next和布尔值marked封装在一起。这些域可以一起或单个地原子更新。可以让每个结点的next域为一个AtomicMarkableReference<Node>。线程可以通过设置结点next域中的标记位来逻辑地删除curr,和其他正在执行add()和remove()的线程共享物理删除:当每个线程遍历链表时,通过物理删除所有被标记的节点来清理链表。


    public Window find(Node head, int key) {
        Node pred = null, curr = null, succ = null;
        boolean[] marked = {false};
        boolean snip;

        retry: while(true) {
            pred = head;
            curr = curr.next.get(marked);
            while(true) {
                succ = curr.next.get(marked); //获取succ,并且查看是被被标记
                while (marked[0]) {//如果被标记了,说明curr被逻辑删除了,需要继续物理删除
                    snip = pred.next.compareAndSet(curr, succ, false, false);//
                    if (!snip) continue retry;
                    curr = succ;
                    succ = curr.next.get(marked);
                }
                //当不需要删除后,才继续遍历
                if (curr.key >= key) {
                    return new Window(pred, curr);
                }
                pred = curr;
                curr = succ;
            }
        }
    }

    public boolean add(T item) {
        int key = item.hashCode();
        while(true) {
            Window window = find(head, key);
            Node pred = window.pred, curr = window.curr;
            if (curr.key == key) {
                return false;
            } else {
                Node node = new Node(item);
                node.next = new AtomicMarkableReference<>(curr, false);
                if (pred.next.compareAndSet(curr, node, false, false)) {
                    return true;
                }
            }
        }
    }

    public boolean remove(T item) {
        int key = item.hashCode();
        boolean sinp;
        while(true) {
            Window window = find(head, key);
            Node pred = window.pred, curr = window.curr;
            if (curr.key != key) {
                return false;
            } else {
                Node succ = curr.next.getReference();
                //要进行删除了,那么就直接将curr.next设置为false,然后在进行真正的物理删除。
                sinp = curr.next.compareAndSet(curr, succ, false, true);
                if (!sinp) {
                    continue;
                }
                pred.next.compareAndSet(curr, succ, false, false);
                return true;
            }
        }
    }


class Node {
          AtomicMarkableReference<Node> next;
}

后记

 文中的代码在我的github的这个repo中都可以找到。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容