Java容器 & 不可变对象(网课整理)

让并发编程变得更简单

说到并发编程,可能很多朋友都会觉得最苦恼的事情就是如何处理共享资源的互斥访问,可能稍不留神,就会导致代码上线后出现莫名其妙的问题,并且大部分并发问题都不是太容易进行定位和复现。

所以即使是非常有经验的程序员,在进行并发编程时,也会非常的小心,内心如履薄冰。大多数情况下,对于资源互斥访问的场景,都是采用加锁的方式来实现对资源的串行访问,来保证 并发安全,如synchronize关键字,Lock锁等。但是这种方案最大的一个难点在于:在进行加锁和解锁时需要非常地慎重。如果加锁或者解锁时机稍有一点偏差,就可能会引发重大问题,然而这个问题Java 编译器无法发现,在进行单元测试、集成测试时可能也发现不了,甚至程序上线后也能正常运行,但是可能突然在某一天,它就莫名其妙地出现了。

既然采用串行方式来访问共享资源这么容易出现问题,那么有没有其他办法来解决呢?

事实上,引起线程安全问题的根本原因在于:多个线程需要同时访问同一个共享资源。

假如没有共享资源,那么多线程安全问题就自然解决了,Java中提供的ThreadLocal机制就是采取的这种思想。

然而大多数时候,线程间是需要使用共享资源互通信息的,如果共享资源在创建之后就完全不再变更,如同一个常量,而多个线程间并发读取该共享资源是不会存在线上安全问题的,因为所有线程无论何时读取该共享资源,总是能获取到一致的、完整的资源状态。

不可变对象就是这样一种在创建之后就不再变更的对象,这种特性使得它们天生支持线程安全,让并发编程变得更简单。

一个同步类的例子

SynchronizedRGB是表示颜色的类,每一个对象代表一种颜色,使用三个整形数表示颜色的三基色,字符串表示颜色名称。

public class SynchronizedRGB {
	private int red; // 颜色对应的红色值 
	private int green; // 颜色对应的绿色值 
	private int blue; // 颜色对应的蓝色值 
	private String name; // 颜色名称
	
    private void check(int red, int green, int blue) {
        if (red < 0 || red > 255 || green < 0 || green > 255
                || blue < 0 || blue > 255) {
            throw new IllegalArgumentException();
		}
	}
	
    public SynchronizedRGB(int red, int green, int blue, String name) {
        check(red, green, blue);
        this.red = red;
        this.green = green;
        this.blue = blue;
        this.name = name;
    }
    
    public void set(int red, int green, int blue, String name) {
        check(red, green, blue);
        synchronized (this) {
            this.red = red;
            this.green = green;
            this.blue = blue;
            this.name = name;
		} 
	}
	
    public synchronized int getRGB() {
        return ((red << 16) | (green << 8) | blue);
	}
	
    public synchronized String getName() {
        return name;
	} 

}

使用SynchronizedRGB时需要小心,避免其处于不一致的状态。例如一个线程执行了以下代码:

扫描二维码关注公众号,回复: 11337363 查看本文章
SynchronizedRGB color = new SynchronizedRGB(0, 0, 0, "Pitch Black");
...
int myColorInt = color.getRGB();      //Statement 1
String myColorName = color.getName(); //Statement 2

如果有另外一个线程在Statement 1之后、Statement 2之前调用了color.set方法,那么myColorInt的值和myColorName的值就会不匹配。为了避免出现这样的结果,必须要像下面这样把这两条语句绑定到一块执行:

synchronized (color) {
    int myColorInt = color.getRGB();
    String myColorName = color.getName();
}

这种不一致的问题只可能发生在可变对象上。

深度埋坑

很多时候一些很严重的bug是由于一个很小的副作用引起的,并且由于副作用通常不容易被察觉, 所以很难在编写代码以及代码review过程中发现,并且即使发现了也可能会花费很大的精力才能定位出来。

class Person {

	private int age; // 年龄
	private String identityCardID; // 身份证号码
	
	public int getAge() {
		return age; 
	}
	
    public void setAge(int age) {
        this.age = age;
	}
    
    public String getIdentityCardID() {
        return identityCardID;
    }
    
    public void setIdentityCardID(String identityCardID) {
        this.identityCardID = identityCardID;
	} 
}

public class Test {

    public static void main(String[] args) {
        Person jack = new Person();
        jack.setAge(101);
        jack.setIdentityCardID("42118220090315234X");
		System.out.println(validAge(jack)); 
		// 后续使用可能没有察觉到jack的age被修改了
		// 为后续埋下了不容易察觉的问题 
	}
	
   	public static boolean validAge(Person person) {
        if (person.getAge() >= 100) {
			person.setAge(100); // 此处产生了副作用
            return false;
        }
        return true;
    }
}

validAge函数本身只是对age大小进行判断,但是在这个函数里面有一个副作用,就是对参数person指向的对象进行了修改,导致在外部的jack指向的对象也发生了变化。

如果Person对象是不可变的,在validAge函数中是无法对参数person进行修改的,从而避免了 validAge 出现副作用,减少了出错的概率。

容器使用

我们在使用HashSet时,如果HashSet中元素对象的状态可变,就会出现元素丢失的情况,比如下面这个例子:

class Person {
	
	private int age; // 年龄
	private String identityCardID; // 身份证号码
	
	public int getAge() {
        return age;
	}
    
    public void setAge(int age) {
        this.age = age;
	}
    
    public String getIdentityCardID() {
        return identityCardID;
	}
    
    public void setIdentityCardID(String identityCardID) {
        this.identityCardID = identityCardID;
	}
    
    @Override
    public boolean equals(Object obj) {
    
        if (obj == null) {
            return false;
		}
        
        if (!(obj instanceof  Person)) {
            return false;
        }
        
        Person personObj = (Person) obj;
        return this.age == personObj.getAge() && this.identityCardID.equals(personObj.getIdentityCardID());
    
    }
    
    @Override
    public int hashCode() {
        return age * 37 + identityCardID.hashCode();
    }
    
}

public class Test {
    public static void main(String[] args) {
        Person jack = new Person();
        jack.setAge(10);
        jack.setIdentityCardID("42118220090315234X");
	} 
}

输出结果:
false

定义不可变对象的策略

以下的一些规则是创建不可变对象的简单策略。并非所有不可变类都完全遵守这些规则,不过这不是编写这些类的程序员们粗心大意造成的,很可能的是他们有充分的理由确保这些对象在创建后不会被修改。但这需要非常复杂细致的分析,并不适用于初学者。

  • 不要提供setter方法。(包括修改字段的方法和修改字段引用对象的方法)
  • 将类的所有字段定义为final、private的。
  • 不允许子类重写方法。简单的办法是将类声明为final,更好的方法是将构造函数声明为私有的, 通过工厂方法创建对象。
  • 如果类的字段是对可变对象的引用,不允许修改被引用对象。
    不提供修改可变对象的方法。
    不共享可变对象的引用。当一个引用被当做参数传递给构造函数,而这个引用指向的是一个外部的可变对象时,一定不要保存这个引用。如果必须要保存,那么创建可变对象的拷贝,然后保存拷贝对象的引用。同样如果需要返回内部的可变对象时,不要返回可变对象本身,而是返回其拷贝。
  • 通过构造器初始化所有成员变量,引用类型的成员变量必须进行深拷贝(deep copy)
  • getter方法不能对外泄露this引用以及成员变量的引用
  • 最好不允许类被继承(非必须)

Collections.unmodifiableXXX

JDK中提供了一系列方法方便我们创建不可变集合

  • java.util.Collections#unmodifiableCollection
  • java.util.Collections#unmodifiableSet
  • java.util.Collections#unmodifiableSortedSet
  • java.util.Collections#unmodifiableNavigableSet
  • java.util.Collections#unmodifiableList
  • java.util.Collections#unmodifiableMap
  • java.util.Collections#unmodifiableSortedMap
  • java.util.Collections#unmodifiableNavigableMap

以unmodifiableList为例,具体实现为
在这里插入图片描述
在这里插入图片描述
可以看出,实际上UnmodifiableList是将入参list的引用复制了一份,同时将所有的修改方法抛出 UnsupportedOperationException;

java 容器
在这里插入图片描述
ArrayList为什么会出现并发问题?

ArrayList是线程不安全的,在多线程并发访问的时候可能会出现问题,如果想使用线程安全的集合类, java自带有vector,也就是说vector是线程安全的。但是arayList的底层是数组实现的,而且可以自动扩容,获得元素或者在数组尾段插入元素的效率高,所以说ArrayList有其独特的优势。

ArrayList并发可能出现的问题

  • 结果不正确
  • null
  • ArrayIndexOutOfBoundsException
//添加元素e
public boolean add(E e) {
	// 确定ArrayList的容量大小
	ensureCapacity(size + 1); // Increments modCount!! 
	// 添加e到ArrayList中
	elementData[size++] = e;
	return true;
}

// 确定ArrarList的容量。
// 若ArrayList的容量不足以容纳当前的全部元素,设置 新的容量=“(原始容量x3)/2 + 1” 
public void ensureCapacity(int minCapacity) {
	// 将“修改统计数”+1,该变量主要是用来实现fail-fast机制的
	modCount++;
	int oldCapacity = elementData.length;
	// 若当前容量不足以容纳当前的元素个数,设置 新的容量=“(原始容量x3)/2 + 1” 
	if (minCapacity > oldCapacity) {
		Object oldData[] = elementData;
		int newCapacity = (oldCapacity * 3)/2 + 1; 
		//如果还不够,则直接将minCapacity设置为当前容量 
		if (newCapacity < minCapacity)
		    newCapacity = minCapacity;
		elementData = Arrays.copyOf(elementData, newCapacity);
	} 
}

赋值语句为:elementData[size++] = e,这条语句可拆分为两条:

  1. elementData[size] = e;
  2. size ++;
    假设A线程执行完第一条语句时,CPU暂停执行A线程转而去执行B线程,此时ArrayList的size并没有加一,这时在ArrayList中B线程就会覆盖掉A线程赋的值,而此时,A线程和B线程先后执行 size++,便会出现值为null的情况;

ArrayIndexOutOfBoundsException异常则是A线程在执行ensureCapacity(size+1)后没有继续执行,此时恰好minCapacity等于oldCapacity,B 线程再去执行,同样由于minCapacity等于oldCapacity,ArrayList并没有增加长度,B线程可以继续执 行赋值(elementData[size] = e)并size ++也执行了,此时,CPU又去执行A线程的赋值操作,由于 size值加了1,size值大于了ArrayList的最大长度, 因此便出现了ArrayIndexOutOfBoundsException异常。

HashMap为什么会出现并发问题?

大多数javaer都知道HashMap是线程不安全的,多线程环境下数据可能会发生错乱,一定要谨慎使用。 这个结论是没错,可是HashMap的线程不安全远远不是数据脏读这么简单,它还有可能会发生死锁, 造成内存飙升100%的问题(JDK1.8修复了这个问题)

  • 脏数据
  • 死锁,造成内存飙升100%的问题

JDK1.8之前HashMap的实现结构
在这里插入图片描述
正常的 ReHash 的过程

  • 假设我们的 hash 算法就是简单的用 key mod一下表的大小(也就是数组的长度)
  • 最上面的是 old hash 表,其中的 Hash 表的 size = 2,所以 key = 3, 7, 5,在 mod 2 以后都冲突在 table[1] 这里了
  • 接下来的三个步骤是 Hash 表 resize 成 4,然后所有的 <key, value> 重新 rehash 的过程
    在这里插入图片描述

并发下的 Rehash

假设有两个线程

do {
	Entry<K,V> next = e.next; // 假设线程一执行到这里就被调度挂起了 
	int i = indexFor(e.hash, newCapacity);
	e.next = newTable[i];
	newTable[i] = e;
	e = next;
} while (e != null);

而线程二执行完成了。于是有下面的这个样子
在这里插入图片描述
线程一被调度回来执行

  • 先是执行 newTalbe[i] = e;
  • 然后是 e = next,导致了 e 指向了 key(7)
  • 而下一次循环的 next = e.next 导致了 next 指向了 key(3)

在这里插入图片描述
线程一接着工作。把 key(7) 摘下来,放到 newTable[i] 的第一个,然后把 e 和 next 往下移
在这里插入图片描述
环形链接出现

  • e.next = newTable[i] 导致 key(3).next 指向了 key(7)
  • 此时的 key(7).next 已经指向了 key(3), 环形链表就这样出现了

在这里插入图片描述
死循环出现的时机

  • 多线程
  • 拆解链条(扩容,hash冲突)

JDK 8 的改进

JDK 8 中采用的是位桶 + 链表/红黑树的方式,当某个位桶的链表的长度超过 8 的时候,这个链表就将转换成红黑树

  • HashMap 不会因为多线程 put 导致死循环(JDK 8 用 head 和 tail 来保证链表的顺序和之前一 样;JDK 7 rehash 会倒置链表元素),但是还会有数据丢失等弊端(并发本身的问题)。因此多 线程情况下还是建议使用 ConcurrentHashMap

在这里插入图片描述

为什么线程不安全

HashMap 在并发时可能出现的问题主要是两方面:

  • 如果多个线程同时使用 put 方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程 put 的数据被覆盖
  • 如果多个线程同时检测到元素个数超过数组大小 * loadFactor,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失

解决方案

  • java.util.Collections#synchronizedCollection(java.util.Collection)
  • java.util.Collections#synchronizedSet(java.util.Set)
  • java.util.Collections#synchronizedSortedSet
  • java.util.Collections#synchronizedNavigableSet
  • java.util.Collections#synchronizedList(java.util.List)
  • java.util.Collections#synchronizedMap

并发容器(ConcurrentHashMap,CopyOnWriteArrayList, CopyOnWriteArraySet)

ConcurrentHashMap

JDK1.7版本的CurrentHashMap的实现原理

在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。

  1. Segment(分段锁)
    ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry 数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了 ReentrantLock)。
  2. 内部结构
    ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当 一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发 访问。如下图是ConcurrentHashMap的内部结构图:
    在这里插入图片描述

从上面的结构我们可以了解到,ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。

第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

该结构的优劣势

  • 坏处
    这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长

  • 好处
    写操作的时候可以只对元素所在的Segment进行加锁即可, 不会影响到其他的Segment,这样,在最理想的情况下, ConcurrentHashMap可以最高同时支持Segment数量大小的写操作 (刚好这些写操作都非常平均地分布在所有的Segment上)。

所以,通过这一种结构,ConcurrentHashMap的并发能力可以大大的提高。

JDK1.8版本的ConurrentHashMap的实现原理

JDK8中ConcurrentHashMap参考了JDK8 HashMap的实现,采用了数组+链表+红黑树的实现方式来设计,内部大量采用CAS操作,这里我简要介绍下CAS。

CAS是compare and swap的缩写,即我们所说的比较交换。cas是一种基于锁的操作,而且是乐观锁。 在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A 的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,若果在第一轮循 环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,不过保证线程安全性。

在JDK8中ConcurrentHashMap的结构,由于引入了红黑树,使得ConcurrentHashMap的实现非常复杂,我们都知道,红黑树是一种性能非常好的二叉查找树,其查找性能为O(logN),但是其实现过程也非常复杂,而且可读性也非常差,DougLea的思维能力确实不是一般人能比的,早期完全采用链表结构时Map的查找时间复杂度为O(N),JDK8中ConcurrentHashMap在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。

在这里插入图片描述
总结

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言, ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的 ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

  • 1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
  • 2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自 ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
  • 3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁 (Node)。
  • 4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
  • 5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

COW写时复制(CopyOnWriteArrayList,CopyOnWriteArraySet)

  • 写入时复制(CopyOnWrite)思想
    写入时复制(CopyOnWrite,简称COW)思想是计算机程序设计领域中的一种优化策略。其核心思想是,如果有多个调用者(Callers)同时要求相同的资源(如内存或者是磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者视图修改资源内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。这过程对其他的调用者都是透明的(transparently)。此做法主要的优点是如果调用者没有修改资源,就不会有副本 (private copy)被创建,因此多个调用者只是读取操作时可以共享同一份资源。
  • CopyOnWriteArrayList的实现原理
    在使用CopyOnWriteArrayList之前,我们先阅读其源码了解下它是如何实现的。以下代码是向 CopyOnWriteArrayList中add方法的实现(向CopyOnWriteArrayList里添加元素),可以发现在添加的时候是需要加锁的,否则多线程写的时候会Copy出N个副本出来。
    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();
        }
    }
    

读的时候不需要加锁,如果读的时候有多个线程正在向CopyOnWriteArrayList添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的CopyOnWriteArrayList。

public E get(int index) {
    return get(getArray(), index);
}

实现很简单,只要了解了CopyOnWrite机制,我们可以实现各种CopyOnWrite容器,并且在不同的应用场景中使用。

  • 几个要点
    • 实现了List接口
    • 内部持有一个ReentrantLock lock = new ReentrantLock();
    • 底层是用volatile transient声明的数组 array
    • 读写分离,写时复制出一个新的数组,完成插入、修改或者移除操作后将新数组赋值给array

CopyOnWrite的应用场景

CopyOnWrite并发容器用于读多写少的并发场景。比如白名单,黑名单,商品类目的访问和更新场景,假如我们有一个搜索网站,用户在这个网站的搜索框中,输入关键字搜索内容,但是某些关键字不允许被搜索。这些不能被搜索的关键字会被放在一个黑名单当中,黑名单每天晚上更新一次。当用户搜索时,会检查当前关键字在不在黑名单当中,如果在,则提示不能搜索。实现代码如下:

/**
* 黑名单服务 *
*/
public class BlackListServiceImpl {

    private static CopyOnWriteMap<String, Boolean> blackListMap = new CopyOnWriteMap<String, Boolean>(1000);
    
    public static boolean isBlackList(String id) {
        return blackListMap.get(id) == null ? false : true;
	}
	
    public static void addBlackList(String id) {
        blackListMap.put(id, Boolean.TRUE);
	}
	
	/**
	 * 批量添加黑名单 *
	 * @param ids 
	 */
    public static void addBlackList(Map<String,Boolean> ids) {
        blackListMap.putAll(ids);
	} 

}

代码很简单,但是使用CopyOnWriteMap需要注意两件事情:

  1. 减少扩容开销。根据实际需要,初始化CopyOnWriteMap的大小,避免写时 CopyOnWriteMap扩容的开销。
  2. 使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器 的复制次数。如使用上面代码里的addBlackList方法。

CopyOnWrite的缺点

CopyOnWrite容器有很多优点,但是同时也存在两个问题,即内存占用问题和数据一致性问题。所以在开发的时候需要注意一下。

  • 内存占用问题

    因为CopyOnWrite的写时复制机制,所以在进行写操作的时候,内存里会同时驻扎两个对象的内存,旧的对象和新写入的对象(注意:在复制的时候只是复制容器里的引用,只是在写的时候会创建新对象添加到新容器里,而旧容器的对象还在使用,所以有两份对象内存)。如果这些对象占用的内存比较大,比如说200M左右,那么再写入100M数据进去,内存就会占用300M,那么这个时候很有可能造成频繁的 Yong GC和Full GC。之前我们系统中使用了一个服务由于每晚使用CopyOnWrite机制更新大对象,造成了每晚15秒的Full GC,应用响应时间也随之变长。

    针对内存占用问题,可以通过压缩容器中的元素的方法来减少大对象的内存消耗,比如,如果元素全是10进制的数字,可以考虑把它压缩成36进制或64进制。或者不使用CopyOnWrite容器,而使用其他的并发容器,如ConcurrentHashMap。

  • 数据一致性问题

    CopyOnWrite容器只能保证数据的最终一致性,不能保证数据的实时一致性。所以如果你希望写入的的数据,马上能读到,请不要使用CopyOnWrite容器。

CopyOnWriteArrayList为什么并发安全且性能比Vector好

我知道Vector是增删改查方法都加了synchronized,保证同步,但是每个方法执行的时候都要去获得锁,性能就会大大下降,而CopyOnWriteArrayList 只是在增删改上加锁,但是读不加锁,在读方面的性能就好于Vector,CopyOnWriteArrayList支持读多写少的并发情况。

猜你喜欢

转载自blog.csdn.net/qq_24095055/article/details/105577025
今日推荐