Java并发编程——基础知识(二)

1.同步容器类

主要包括两者:VectorHashTable。这些类实现线程安全的方式主要是将它们的状态封装起来,对每个公有方法都进行同步,使得每次只有一个线程能访问这些容器:boom:

1.1 问题一:同步容器类在所有情况下都是线程安全的吗:question:

答案是:No!:no_good:
的确,如果只是很简单地,原子地去使用这些同步容器类的方法的话是线程安全的,但当进行一些复合操作的时候,例如迭代,条件语句等,在并发环境下就很容易出现问题!
例如以下代码::eyes:

 public static Object getLast(Vector vector) {
     int lastindex = vector.size() - 1; //1
     return vector.get(lastindex);     //2
 }
 public static Object deleteLast(Vector vector) {
     int lastindex = vector.size() - 1;//1
     return vector.remove(lastindex); //2
 }

如果此时有两个线程分别同时执行get和delete方法,此时假设他们通过了对应的第一行语句,即分别获取了这个同步容器类的大小,ok,下一步,只能有一个线程进行操作,假设此时恰好是执行delete方法,导致容器变小,之后退出方法。然后另一个线程执行get方法,因为此时容器的容量减小,故导致抛出数组访问越界异常!

通过对上面情况的讨论,我们应该知道,对这些容易出错的复合操作,也应该加上锁来保证同步,但此时并发性也会大大降低

1.2 迭代器及其问题

对于同步容器类的迭代,其实开发者并没有考虑到并发修改的问题,即在并发环境下,如果其他线程在该迭代器进行迭代期间而进行修改,那很有可能抛出ConcurrentModificatinException!——fail fast!:boom:

    public synchronized Iterator<E> iterator() {
        return new Itr();
    }

    /**
     * An optimized version of AbstractList.Itr
     */
    private class Itr implements Iterator<E> {
        int cursor;       // index of next element to return
        int lastRet = -1; // index of last element returned; -1 if no such
        int expectedModCount = modCount;

        public boolean hasNext() {
            // Racy but within spec, since modifications are checked
            // within or after synchronization in next/previous
            return cursor != elementCount;
        }

        public E next() {
            synchronized (Vector.this) {
                checkForComodification();
                //.......
            }
        }

        public void remove() {
            if (lastRet == -1)
                throw new IllegalStateException();
            synchronized (Vector.this) {
                checkForComodification();
                //.......
            }
            cursor = lastRet;
            lastRet = -1;
        }

        @Override
        public void forEachRemaining(Consumer<? super E> action) {
            Objects.requireNonNull(action);
            synchronized (Vector.this) {
                   //.......
                checkForComodification();
            }
        }

        final void checkForComodification() {
            if (modCount != expectedModCount)
                throw new ConcurrentModificationException();
        }
    }

上面是同步容器类Vector的迭代器源码,我们可以发现,虽然其Next方法做到了同步,但是致命的一点就是checkForComodification检查方法没有进行同步!也就意味着在并发情况下很有可能导致fail-fast情况。而且我们仔细看,其实它的remove方法也是有问题,其上锁的区块并没有包含整个方法,同步范围小,也是很容易出问题的!:imp:

注意:fail——fast机制在单线程环境下也是会出现的,即在迭代期间没有通过迭代器本身进行删除,而是通过容器直接添加,那么会很容易陷入快速失败的情况!

那我们该如何解决呢 ?:anguished:
  • :one: 迭代期间加锁!
    这并不是一个好方法,如果容器的容量很大,那么迭代将耗费很长时间,此时其他线程都处于阻塞状态,会极大地降低吞吐量和CPU的利用率!
    注意:我们知道在用迭代器的地方加锁,可是要注意某些情况下迭代器会隐藏!!!!比如容器的hashcode和equals等方法也会间接进行迭代!!所以要时刻警惕这些场景!
  • :two: 克隆容器!
    将容器克隆出副本,并在副本进行迭代,克隆期间还是要加锁!这方法也有问题,就是取决于容器克隆的性能开销

1.3 总结

同步容器类其实是一种古老的是实现线程安全的方法,你完全也可以自己通过Collections.SynchronizedXXX静态工厂方法将你的容器同步。但由于其所有方法都是同步的,复合操作的不安全性以及迭代器的问题,在并发场景下,很少再用这些古老的,效率低下的同步容器了,取而代之的是下面将要介绍的 并发容器!

2.并发容器

由于并发场景下,同步容器类表现出令人不满意的情况——并发性低,吞吐量低。
并发容器应运而生:ConcurrentHashMap 代替 同步的 Map,CopyOnWriteArraylList 代替同步的list 以及还有 ConcurrentSkipListMap代替同步的 SortedMap等等

2.1 ConcurrentHashMap

  • JDK 1.7及以前,ConcurrentHashMap采用的是分段锁 setment继承自ReentrantLock,即Segment 数组 + HashEntry 数组 + 链表。将锁的粒度由之前的整个容器降低为一段一段,在并发环境下实现更高的吞吐量
    Java并发编程——基础知识(二)

  • JDK 1.8之后,采用Node 数组 + 链表 / 红黑树,并发控制使用 synchronized 和 CAS 来操作。将锁的粒度从之前的段到了现在的Node结点!并且采用synchronized 和CAS操作,使并发程度更高,性能更好。

    Java并发编程——基础知识(二)

    注意:虽然ConcurrentHashMap表现出很好的并发性,但是其一些方法是被弱化了的,比如size 和 isEmpty。比如size方法返回的允许是一个近似值,事实上这样的方法用处小,因为返回值总是在不断变化。因此这些操作的需求被弱化了,更多的是提升put ,get等操作的性能!

2.2 CopyOnWriteArraylList

在很多应用场景中,读操作可能会远远大于写操作。由于读操作根本不会修改原有的数据,因此对于每次读取都进行加锁其实是一种资源浪费。我们应该允许多个线程同时访问 List 的内部数据,毕竟读取操作是安全的。
‘写入时复制’容器的线程安全性是每次修改时,都会创建并重新发布一个新的容器副本!即读取是完全不用加锁的,写入也不会阻塞读取操作,因为写入操作是在新的副本上操作!只有写入和写入之间需要进行同步等待。这样一来,读操作的性能就会大幅度提升。
直接上源码::eyes:

    public E get(int index) { //读取操作不加锁
        return get(getArray(), index);
    }
     public E set(int index, E element) {
        final ReentrantLock lock = this.lock;
        lock.lock(); //上锁
        try {
            Object[] elements = getArray();
            E oldValue = get(elements, index);

            if (oldValue != element) {
                int len = elements.length;
                Object[] newElements = Arrays.copyOf(elements, len);
                newElements[index] = element;
                setArray(newElements);
            } else {
                // Not quite a no-op; ensures volatile write semantics
                setArray(elements);
            }
            return oldValue;
        } finally {
            lock.unlock(); //解锁
        }
    }
    public boolean add(E e) {
        final ReentrantLock lock = this.lock;
        lock.lock();//上锁
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();//解锁
        }
    }

通过以上源码我们可以发现:

  • CopyOnWriteArraylList 的读读共享,读写共享,写读共享,唯有写写互斥!
  • 实现写读共享的机制就是通过在写时先拷贝原数组,然后操作完成之后将原数组的引用指向新数组!

但这有一定的问题,CopyOnWriteArraylList容器是无法保证读写的瞬时一致性,只能保证最终一致性!试想同时有两个线程,线程A将某个i位置的值更新,而线程B读取i位置的值,由于拷贝数组的开销,很有可能导致线程B先读取到原先的旧值,即出现 “脏读”!还有就是之前讨论过的,拷贝数组的开销!:-1:

虽然有一定的问题,但是在读操作或者迭代操作多于写操作的场景下,CopyOnWriteArraylList的性能是有目共睹的!:+1:

2.3 ConcurrentLinkedQueue

Java 提供的线程安全的 Queue 可以分为阻塞队列非阻塞队列,其中阻塞队列的典型例子是 BlockingQueue,非阻塞队列的典型例子是 ConcurrentLinkedQueue,在实际应用中要根据实际需要选用阻塞队列或者非阻塞队列。 阻塞队列可以通过加锁来实现,非阻塞队列可以通过 CAS 操作实现。

2.4 BlockingQueue

阻塞队列(BlockingQueue)被广泛使用在“生产者-消费者”问题中,其原因是 BlockingQueue 提供了可阻塞的插入和移除的方法。当队列容器已满,生产者线程会被阻塞,直到队列未满;当队列容器为空时,消费者线程会被阻塞,直至队列非空时为止。

Java并发编程——基础知识(二)

2.4.1 ArrayBlockingQueue

2.4.2 LinkedBlockingQueue

2.4.3 PriorityBlockingQueue

本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!