Java并发编程之同步容器

简介

同步容器主要分两类,一种是Vector这样的普通类,一种是通过Collections的工厂方法创建的内部类

虽然很多人都对同步容器的性能低有偏见,但它也不是一无是处,在这里我们插播一条阿里巴巴的开发手册规范:

高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

可以看到,只有在高并发才会考虑到锁的性能问题,所以在一些小而全的系统中,同步容器还是有用武之地的(当然也可以考虑并发容器,后面章节再讨论)

一、什么是同步容器

定义:就是把容器类同步化,这样我们在并发中使用容器时,就不用手动同步,因为内部已经自动同步了

例子:比如Vector就是一个同步容器类,它的同步化就是把内部的所有方法都上锁(有的重载方法没上锁,但是最终调用的方法还是有锁的)

源码:Vector.add

// 通过synchronized为add方法上锁
public synchronized boolean add(E e) {
  modCount++;
  ensureCapacityHelper(elementCount + 1);
  elementData[elementCount++] = e;
  return true;
}

同步容器主要分两类:

1.普通类:Vector、Stack、HashTable

2.内部类:Collections创建的内部类,比如Collections.SynchronizedList、 Collections.SynchronizedSet等

那这两种有没有区别呢?

当然是有的,刚开始的时候(Java1.0)只有第一种同步容器(Vector等)

但是因为Vector这种类太局气了,它就想着把所有的东西都弄过来自己搞(Vector通过toArray转为己有,HashTable通过putAll转为己有);

源码:Vector构造函数

public Vector(Collection<? extends E> c) {
	// 这里通过toArray将传来的集合 转为己有
  elementData = c.toArray();
  elementCount = elementData.length;
  // c.toArray might (incorrectly) not return Object[] (see 6260652)
  if (elementData.getClass() != Object[].class)
    elementData = Arrays.copyOf(elementData, elementCount, Object[].class);
}

所以就有了第二种同步容器类(通过工厂方法创建的内部容器类),它就比较聪明了,它只是把原有的容器进行包装(通过this.list = list直接指向需要同步的容器),然后局部加锁,这样一来,即生成了线程安全的类,又不用太费力;

源码:Collections.SynchronizedList构造函数

SynchronizedList(List<E> list) {
  super(list);
  // 这里只是指向传来的list,不转为己有,后面的相关操作还是基于原有的list集合
  this.list = list;
}

他们之间的区别如下:

两种同步容器的区别 普通类 内部类
锁的对象 不可指定,只能this 可指定,默认this
锁的范围 方法体(包括迭代) 代码块(不包括迭代)
适用范围 窄-个别容器 广-所有容器

这里我们重点说下锁的对象:

  • 普通类锁的是当前对象this(锁在方法上,默认this对象);
  • 内部类锁的是mutex属性,这个属性默认是this,但是可以通过构造函数(或工厂方法)来指定锁的对象

源码:Collections.SynchronizedCollection构造函数

final Collection<E> c;  // Backing Collection
// 这个就是锁的对象
final Object mutex;     // Object on which to synchronize

SynchronizedCollection(Collection<E> c) {
  this.c = Objects.requireNonNull(c);
// 初始化为 this
  mutex = this;
}

SynchronizedCollection(Collection<E> c, Object mutex) {
  this.c = Objects.requireNonNull(c);
  this.mutex = Objects.requireNonNull(mutex);
}

这里要注意一点就是,内部类的迭代器没有同步(Vector的迭代器有同步),需要手动加锁来同步

源码:Vector.Itr.next 迭代方法(有上锁)

public E next() {
  synchronized (Vector.this) {
    checkForComodification();
    int i = cursor;
    if (i >= elementCount)
      throw new NoSuchElementException();
    cursor = i + 1;
    return elementData(lastRet = i);
  }
}

源码:Collections.SynchronizedCollection.iterator 迭代器(没上锁)

public Iterator<E> iterator() {
  // 这里会直接实现类的迭代器(比如ArrayList,它里面的迭代器肯定是没上锁的)
  return c.iterator(); // Must be manually synched by user!
}

二、为什么要有同步容器

因为普通的容器类(比如ArrayList)是线程不安全的,如果是在并发中使用,我们就需要手动对其加锁才会安全,这样的话就很麻烦;

所以就有了同步容器,它来帮我们自动加锁

下面我们用代码来对比下

线程不安全的类:ArrayList

public class SyncCollectionDemo {

    private List<Integer> listNoSync;

    public SyncCollectionDemo() {
        this.listNoSync = new ArrayList<>();
    }

    public void addNoSync(int temp){
        listNoSync.add(temp);
    }

    public static void main(String[] args) throws InterruptedException {
        SyncCollectionDemo demo = new SyncCollectionDemo();
				// 创建10个线程
        for (int i = 0; i < 10; i++) {
					// 每个线程执行100次添加操作
          new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    demo.addNoSync(j);
                }
            }).start();
        }
    }
}

上面的代码看似没问题,感觉就算有问题也应该是插入的顺序比较乱(多线程交替插入)

但实际上运行会发现,可能会报错数组越界,如下所示:

原因有二:

因为ArrayList.add操作没有加锁,导致多个线程可以同时执行add操作add操作时,如果发现list的容量不足,会进行扩容,但是由于多个线程同时扩容,就会出现扩容不足的问题

源码:ArrayList.grow扩容

// 扩容方法
private void grow(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
				// 这里可以看到,每次扩容增加一半的容量
  			int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity < 0)
            newCapacity = minCapacity;
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        // minCapacity is usually close to size, so this is a win:
        elementData = Arrays.copyOf(elementData, newCapacity);
    }

可以看到,扩容是基于之前的容量进行的,因此如果多个线程同时扩容,那扩容基数就不准确了,结果就会有问题

线程安全的类:Collections.SynchronizedList

/**
 * <p>
 *  同步容器类:为什么要有它
 * </p>
 *
 * @author: JavaLover
 * @time: 2021/5/3
 */
public class SyncCollectionDemo {

    private List<Integer> listSync;

    public SyncCollectionDemo() {
      	// 这里包装一个空的ArrayList
        this.listSync = Collections.synchronizedList(new ArrayList<>());
    }

    public void addSync(int j){
      	// 内部是同步操作: synchronized (mutex) {return c.add(e);}
        listSync.add(j);
    }

    public static void main(String[] args) throws InterruptedException {
        SyncCollectionDemo demo = new SyncCollectionDemo();

        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                for (int j = 0; j < 100; j++) {
                    demo.addSync(j);
                }
            }).start();
        }

        TimeUnit.SECONDS.sleep(1);
      	// 输出1000
        System.out.println(demo.listSync.size());
    }
}

输出正确,因为现在ArrayList被Collections包装成了一个线程安全的类

这就是为啥会有同步容器的原因:因为同步容器使得并发编程时,线程更加安全

三、同步容器的优缺点

一般来说,都是先说优点,再说缺点

但是我们这次先说优点

优点:

  • 并发编程中,独立操作是线程安全的,比如单独的add操作

缺点(是的,优点已经说完了):

  • 性能差,基本上所有方法都上锁,完美的诠释了“宁可错杀一千,不可放过一个”
  • 复合操作,还是不安全,比如putIfAbsent操作(如果没有则添加)
  • 快速失败机制,这种机制会报错提示ConcurrentModificationException,一般出现在当某个线程在遍历容器时,其他线程恰好修改了这个容器的长度

为啥第三点是缺点呢?

因为它只能作为一个建议,告诉我们有并发修改异常,但是不能保证每个并发修改都会爆出这个异常

爆出这个异常的前提如下:

源码:Vector.Itr.checkForComodification 检查容器修改次数

final void checkForComodification() {
  // modCount:容器的长度变化次数, expectedModCount:期望的容器的长度变化次数
  if (modCount != expectedModCount)
    throw new ConcurrentModificationException();
}

那什么情况下并发修改不会爆出异常呢?有两种:

1.遍历没加锁的情况:对于第二种同步容器(Collections内部类)来说,假设线程A修改了modCount的值,但是没有同步到线程B,那么线程B遍历就不会发生异常(但实际上问题已经存在了,只是暂时没有出现)

2.依赖线程执行顺序的情况:对于所有的同步容器来说,假设线程B已经遍历完了容器,此时线程A才开始遍历修改,那么也不会发生异常

代码就不贴了,大家感兴趣的可以直接写几个线程遍历试试,多运行几次,应该就可以看到效果(不过第一种情况也是基于理论分析,实际代码我这边也没跑出来)

根据阿里巴巴的开发规范:不要在 foreach 循环里进行元素的 remove/add 操作。remove 元素请使用 Iterator方式,如果并发操作,需要对 Iterator 对象加锁。

这里解释下,关于List.remove和Iterator.remove的区别

  • Iterator.remove:会同步修改expectedModCount=modCount
  • list.remove:只会修改modCount,因为expectedModCount属于iterator对象的属性,不属于list的属性(但是也可以间接访问)

源码:ArrayList.remove移除元素操作

public E remove(int index) {
        rangeCheck(index);
				// 1. 这里修改了 modCount
        modCount++;
        E oldValue = elementData(index);

        int numMoved = size - index - 1;
        if (numMoved > 0)
            System.arraycopy(elementData, index+1, elementData, index,
                             numMoved);
        elementData[--size] = null; // clear to let GC do its work

        return oldValue;
    }

源码:ArrayList.Itr.remove迭代器移除元素操作

public void remove() {
            if (lastRet < 0)
                throw new IllegalStateException();
            checkForComodification();

            try {
              	// 1. 这里调用上面介绍的list.romove,修改modCount
                ArrayList.this.remove(lastRet);
                cursor = lastRet;
                lastRet = -1;
              	// 2. 这里再同步更新 expectedModCount
                expectedModCount = modCount;
            } catch (IndexOutOfBoundsException ex) {
                throw new ConcurrentModificationException();
            }
        }

由于同步容器的这些缺点,于是就有了并发容器(下期来介绍)

四、同步容器的使用场景

多用在并发编程,但是并发量又不是很大的场景,比如一些简单的个人博客系统(具体多少并发量算大,这个也是分很多情况而论的,并不是说每秒处理超过多少个请求,就说是高并发,还要结合吞吐量、系统响应时间等多个因素一起考虑)

具体点来说的话,有以下几个场景:

  • 写多读少,这个时候同步容器和并发容器的性能差别不大(并发容器可以并发读)
  • 自定义的复合操作,比如getLast等操作(putIfAbsent就算了,因为并发容器有默认提供这个复合操作)
  • 等等

总结

什么是同步容器:就是把容器类同步化,这样我们在并发中使用容器时,就不用手动同步,因为内部已经自动同步了

为什么要有同步容器:因为普通的容器类(比如ArrayList)是线程不安全的,如果是在并发中使用,我们就需要手动对其加锁才会安全,这样的话就很太麻烦;所以就有了同步容器,它来帮我们自动加锁

同步容器的优缺点:

优点

独立操作,线程安全

缺点 复合操作,还是不安全,性能差快速失败机制,只适合bug调试

同步容器的使用场景

多用在并发量不是很大的场景,比如个人博客、后台系统等

具体点来说,有以下几个场景:

  • 写多读少:这个时候同步容器和并发容器差别不是很大
  • 自定义复合操作:比如getLast等复合操作,因为同步容器都是单个操作进行上锁的,所以可以很方便地去拼接复合操作(记得外部加锁)

到此这篇关于Java并发编程之同步容器的文章就介绍到这了,更多相关Java同步容器内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • Java并发编程之线程之间的共享和协作

    一.线程间的共享 1.1 ynchronized内置锁 用处 Java支持多个线程同时访问一个对象或者对象的成员变量 关键字synchronized可以修饰方法或者以同步块的形式来进行使用 它主要确保多个线程在同一个时刻,只能有一个线程处于方法或者同步块中 它保证了线程对变量访问的可见性和排他性(原子性.可见性.有序性),又称为内置锁机制. 对象锁和类锁 对象锁是用于对象实例方法,或者一个对象实例上的 类锁是用于类的静态方法或者一个类的class对象上的 类的对象实例可以有很多个,但是每个类只有

  • Java并发编程之常用的辅助类详解

    1.CountDownLatch 1.2.示例:班长锁门问题 问题描述:假如有7个同学晚上上自习,钥匙在班长手上,并且要负责锁门.班长必须要等所有人都走光了,班长才能关灯锁门.这6个同学的顺序是无序的,不知道它们是何时离开.6个同学各上各的自习,中间没有交互.假如说6个学生是普通线程,班长是主线程,如何让主线程要等一堆线程运行完了,主线程才能运行完成呢. public class CountDownLatchDemo { public static void main(String[] args

  • Java 并发编程中如何创建线程

    简介 线程是基本的调度单位,它被包含在进程之中,是进程中的实际运作单位,它本身是不会独立存在.一个进程至少有一个线程,进程中的多个线程共享进程的资源. Java中创建线程的方式有多种如继承Thread类.实现Runnable接口.实现Callable接口以及使用线程池的方式,线程池将在后面文章中单独介绍,这里先介绍另外三种方式. 继承Thread类 优点:在run方法里可以用this获取到当前线程. 缺点:由于Java不支持多继承,所以如果继承了Thread类后就不能再继承其他类. public

  • Java并发编程之Exchanger方法详解

    简介 Exchanger是一个用于线程间数据交换的工具类,它提供一个公共点,在这个公共点,两个线程可以交换彼此的数据. 当一个线程调用exchange方法后将进入等待状态,直到另外一个线程调用exchange方法,双方完成数据交换后继续执行. Exchanger的使用 方法介绍 exchange(V x):阻塞当前线程,直到另外一个线程调用exchange方法或者当前线程被中断. x : 需要交换的对象. exchange(V x, long timeout, TimeUnit unit):阻塞

  • Java同步容器和并发容器详解

    同步容器 在 Java 中,同步容器主要包括 2 类: Vector.Stack.HashTableCollections 类中提供的静态工厂方法创建的类(由 Collections.synchronizedXxxx 等方法) Collections类中提供的静态工厂方法创建的类 Vector 实现了 List 接口,Vector 实际上就是一个数组,和 ArrayList 类似,但是Vector 中的方法都是 synchronized 方法,即进行了同步措施. Stack 也是一个同步容器,它

  • Java从同步容器到并发容器的操作过程

    引言 容器是Java基础类库中使用频率最高的一部分,Java集合包中提供了大量的容器类来帮组我们简化开发,我前面的文章中对Java集合包中的关键容器进行过一个系列的分析,但这些集合类都是非线程安全的,即在多线程的环境下,都需要其他额外的手段来保证数据的正确性,最简单的就是通过synchronized关键字将所有使用到非线程安全的容器代码全部同步执行.这种方式虽然可以达到线程安全的目的,但存在几个明显的问题:首先编码上存在一定的复杂性,相关的代码段都需要添加锁.其次这种一刀切的做法在高并发情况下性

  • .NET并发编程之函数闭包

    函数式编程 一个函数输出当做另一个函数输入.有时候一个复杂问题,我们拆分成很多个步骤函数,这些函数组合起来调用解决一个复杂问题. 在C#中不支持函数组合,但可以直接像这样调用B(A(n)),这也是函数组合,但这不利于阅读,人们习惯从左往右阅读,而不是相反的方向.通过创建扩展方法可以任何组合两个函数,像下面这样 Func<A,C> Compose<A,B,C>(this Func<A.B> f ,Func<B,C> g)=>(n)=>g(f(n))

  • 详解Java并发编程之内置锁(synchronized)

    简介 synchronized在JDK5.0的早期版本中是重量级锁,效率很低,但从JDK6.0开始,JDK在关键字synchronized上做了大量的优化,如偏向锁.轻量级锁等,使它的效率有了很大的提升. synchronized的作用是实现线程间的同步,当多个线程都需要访问共享代码区域时,对共享代码区域进行加锁,使得每一次只能有一个线程访问共享代码区域,从而保证线程间的安全性. 因为没有显式的加锁和解锁过程,所以称之为隐式锁,也叫作内置锁.监视器锁. 如下实例,在没有使用synchronize

  • Java 并发编程ArrayBlockingQueue的实现

    一.简介 ArrayBlockingQueue 顾名思义:基于数组的阻塞队列.数组是要指定长度的,所以使用 ArrayBlockingQueue 时必须指定长度,也就是它是一个有界队列.它实现了 BlockingQueue 接口,有着队列.集合以及阻塞队列的所有方法. ArrayBlockingQueue 是线程安全的,内部使用 ReentrantLock 来保证.ArrayBlockingQueue 支持对生产者线程和消费者线程进行公平的调度.当然默认情况下是不保证公平性的,因为公平性通常会降

  • Java并发编程之Semaphore的使用简介

    简介 Semaphore是用来限制访问特定资源的并发线程的数量,相对于内置锁synchronized和重入锁ReentrantLock的互斥性来说,Semaphore可以允许多个线程同时访问共享资源. Semaphored的使用 构造方法 Semaphore(int permits):创建Semaphore,并指定许可证的数量.(公平策略为非公平) Semaphore(int permits, boolean fair):创建Semaphore,并指定许可证的数量和公平策略. 核心方法 acqu

  • Java并发编程之CountDownLatch源码解析

    一.前言 CountDownLatch维护了一个计数器(还是是state字段),调用countDown方法会将计数器减1,调用await方法会阻塞线程直到计数器变为0.可以用于实现一个线程等待所有子线程任务完成之后再继续执行的逻辑,也可以实现类似简易CyclicBarrier的功能,达到让多个线程等待同时开始执行某一段逻辑目的. 二.使用 一个线程等待其它线程执行完再继续执行 ...... CountDownLatch cdl = new CountDownLatch(10); Executor

随机推荐