集合与线程

使用 Iterator 修改集合中的数据

ArrayList 中针对 iterator 接口的实现
在这里插入图片描述
创建 Itr 对象时会记录当前集合的 modCount 修改次数
在这里插入图片描述
当调用 Itr 对象的 next 方法时首先判定修改次数是否发生变化,如果已经修改了则抛出异常
在这里插入图片描述

Java 集合中的快速失败机制

有线程在遍历集合的同时,有另外线程进行了集合结构的修改,则会引发异常,异常是告知

遍历集合的线程当前集合已经发生了改变[modcount++],要求重新获取遍历器

迭代器的快速失败行为无法得到保证,因为一般来说,不可能对是否出现不同步并发修改做出 任 何 硬 性 保 证 。 快 速 失 败 迭 代 器 会 尽 最 大 努 力 抛 出ConcurrentModificationException,为提高这类迭代器的正确性而编写一个依赖于此异常的程序是错误的做法:迭代器的快速失败行为应该仅用于检测 bug。
它是 Java 集合的一种错误检测机制。当多个线程对集合进行结构上的改变的操作时,有可能会产生 fail-fast 机制。记住是有可能,而不是一定。

两个线程同时修改 List,编程不会有问题,但是执行结果不可提前估算,所以不能使用 ConcurrentModificationException 不会始终指出对象已经由不同线程并发修改,如果单线程违反了规则,同样也有可能会抛出该异常。

迭代器在调用 next()、remove()方法时都是调用 checkForComodification()方法,该方法主要就是检测 modCount == expectedModCount ? 若不等则抛出
ConcurrentModificationException 异常,从而产生 fail-fast 机制

解决并发修改问题的方法

使用同步处理----并发性不能使用 wait 方法,因为会释放锁
使用并发集合 java.util.concurrent 包

特殊情况:在一个线程中遍历数据的同时进行修改
解决方案是:不要采用 List 中提供的 remove 方法,而是使用 Iterator 中提供的 remove方法,则不会再报异常
在这里插入图片描述

针对List的特殊迭代器ListIterator

Iterator 只能单向遍历集合中的元素,只支持删除元素;而 ListIterator 可以双向遍历,并允许添加、修改和删除元素
在这里插入图片描述
在这里插入图片描述

java并发编程

三种性质
可见性:一个线程对共享变量的修改,另一个线程能立刻看到。缓存可导致可见性问题原子性:一个或多个 CPU 执行操作不被中断。线程切换可导致原子性问题
有序性:编译器优化可能导致指令顺序发生改变。编译器优化可能导致有序性问题。
三个问题
安全性问题:线程安全
活跃性问题:死锁、活锁、饥饿性能问题:
使用无锁结构:TLS 线程局部存储,Copy-On-Write,乐观锁;Java 的原子类,
Disruptor 无锁队列
减少锁的持有时间:让锁细粒度。如 ConcurrentHashmap;再如读写锁,读无
锁写有锁

volatile

C 语言中的原意:禁用 CPU 缓存,从内存中读出和写入。Java 语言的引申义:Java 会将变量立刻写入内存,其他线程读取时直接从内存读(普通变量改变后,什么时候写入内存是不一定的)、禁止指令重排序

解决问题:
保证可见性保证有序性
不能保证原子性
是一种轻量级的线程安全处理机制互斥锁

sychronized

锁对象:非静态 this,静态 Class,括号 Object 参数

预防死锁:
互斥:不能破坏
占有且等待:同时申请所有资源
不可抢占:sychronized 解决不了,Lock 可以解决
循环等待:给资源设置 id 字段,每次都是按顺序申请锁等待通知机制:
wait、notify、notifyAll 重要说明
在 JDK 1.6 之前,synchronized 是重量级锁,效率低下
从 JDK 1.6 开始,synchronized 做了很多优化,如偏向锁、轻量级锁、自旋锁、适应性自旋锁、锁消除、锁粗化等技术来减少锁操作的开销

synchronized 同步锁一共包含四种状态:无锁、偏向锁、轻量级锁、重量级锁,它会随着竞争情况逐渐升级。synchronized 同步锁可以升级但是不可以降级,目的是为了提高获取锁和释放锁的效率

synchronized 修饰的代码块: 通过反编译.class 文件,通过查看字节码可以得到:在代码块中使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令指明同步代码块的结束位置

synchronized 修饰的方法:查看字节码可以得到:在同步方法中会包含 ACC_SYNCHRONIZED 标记符。该标记符指明了该方法是一个同步方法,从而执行相应的同步调用

公平锁/非公平锁

公平锁是指多个线程按照申请锁的顺序来获取锁。

非公平锁是指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。有可能,会造成优先级反转或者饥饿现象。

对于 Java ReentrantLock 而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
在这里插入图片描述

对于 synchronized 而言,也是一种非公平锁。由于其并不像 ReentrantLock 是通过AQS 的来实现线程调度,所以并没有任何办法使其变成公平锁

可重入锁

可重入锁又名递归锁,是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取该锁。

对于 Java ReentrantLock 而言, 他的名字就可以看出是一个可重入锁,其名字是 Re entrant Lock 重新进入锁。

对于 Synchronized 而言,也是一个可重入锁。可重入锁的一个好处是可一定程度避免死锁。

特殊的是 ReentrantReadWriteLock 可重入读写锁:读锁属于共享锁,写锁属于独占锁一个线程持有读锁获取写锁时互斥持有写获取读没问题

ReentrantReadWriteLock 是 Lock 的另一种实现方式,ReentrantLock 是一个排他锁,同一时间只允许一个线程访问,而 ReentrantReadWriteLock 允许多个读线程同时访问,但不允许写线程和读线程、写线程和写线程同时访问。相对于排他锁,提高了并发性。在实际应用中,大部分情况下对共享数据(如缓存)的访问都是读操作远多于写操作,这时
ReentrantReadWriteLock 能够提供比排他锁更好的并发性和吞吐量。

编程实现:
//创建重入读写锁对象,其中可以引入参数 boolean 用于设定是否使用公平锁,默认非公
平锁 private static final ReentrantReadWriteLock lock=new ReentrantReadWriteLock();

lock.readLock().lock();//申请读锁
lock.readLock().unlock();//释放所拥有的读锁,如果没有拥有读锁,则释放时报异常IllegalMonitorStateException

//申请获取写锁
lock.writeLock().lock();//由于写锁属于独占锁,所以必须释放读锁后才能申请 lock.writeLock().unlock();

lock.getHoldCount():int 获取所拥有的锁个数

在一个线程中读锁和读锁、写锁和写锁不互斥----可重入的概念,在一个线程中持有读不能申请写,但是持有写可以申请读

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。

对于 Java ReentrantLock 是独享锁。但是对于 Lock 的另一个实现类 ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,多线程中读写\写读\写写的过程是互斥的。

独享锁与共享锁也是通过 AQS 来实现的,通过实现不同的方法,来实现独享或者共享。
对于 synchronized 而言,当然是独享锁。

乐观锁/悲观锁

乐观锁与悲观锁不是指具体的什么类型的锁,而是指看待并发同步的角度。

悲观锁认为对于同一个数据的并发操作,一定是会发生修改的,哪怕没有修改,也会认为修改。因此对于同一个数据的并发操作,悲观锁采取加锁的形式。悲观的认为,不加锁的并发操作一定会出问题。

乐观锁则认为对于同一个数据的并发操作,是不会发生修改的。在更新数据的时候,会采用尝试更新,不断重试的方式更新数据。乐观的认为,不加锁的并发操作是没有事情的。 -CAS 悲观锁适合写操作非常多的场景,乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。

悲观锁在 Java 中的使用,就是利用各种锁。
乐观锁在 Java 中的使用,是无锁编程,常常采用的是 CAS 算法,典型的例子就是原子类,通过 CAS 自旋实现原子操作的更新。

偏向锁/轻量级锁/重量级锁

这三种锁是指锁的状态,并且是针对 Synchronized。在 Java 5 通过引入锁升级的机制来实现高效 Synchronized。这三种锁的状态是通过对象监视器在对象头中的字段来表明的。

偏向锁是指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价。

轻量级锁是指当锁是偏向锁的时候,被另一个线程所访问,偏向锁就会升级为轻量级锁,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。

重量级锁是指当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。

自旋锁

在 Java 中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗 CPU
自旋锁的本质:执行几个空方法,稍微等一等,也许是一段时间的循环,也许是几行空的汇编指令。

tryLock 是防止自锁的一个重要方式。
tryLock()方法是有返回值的,它表示用来尝试获取锁,如果获取成功,则返回 true,如果获取失败(即锁已被其他线程获取),则返回 false,这个方法无论如何都会立即返回。在拿不到锁时不会一直在那等待。

while(!lock.tryLock()){   //  lock.lock(); 
   //计算 1+2+...+1000=? 
} 
 

锁消除

即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除,依据来源于逃逸分析的数据支持,那么是什么是逃逸分析?对于虚拟机来说需要使用数据流分析来确定是否消除变量底层框架的同步代码,因为有许多同步的代码不是自己写的

线程阻塞的代价

java 的线程是映射到操作系统原生线程之上的,如果要阻塞或唤醒一个线程就需要操作系统介入,需要在用户态与核心态之间切换,这种切换会消耗大量的系统资源,因为用户态与内核态都有各自专用的内存空间,专用的寄存器等,用户态切换至内核态需要传递给许多变量、参数给内核,内核也需要保护好用户态在切换时的一些寄存器值、变量等,以便内核态调用结束后切换回用户态继续工作。

如果线程状态切换是一个高频操作时,这将会消耗很多 CPU 处理时间;

如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。
synchronized 会导致争用不到锁的线程进入阻塞状态,所以说它是 java 语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM 从 1.5 开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

同步集合和并发集合

同步集合类:Hashtable、Vector 方法上有同步约束 (jdk1.0)
同步集合包装类:Collections.synchronizedMap(newHashMap<>()) 和Collections.synchronizedList(newArrayList<>()) —使用的是全局锁
并发集合类:ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteHashSet

性能比较

同步集合比并发集合会慢得多,主要原因是锁,同步集合会对整个 Map 或 List 加锁

并发集合的实现原理

ConcurrentHashMap[jdk1.7]把整个 Map 划分成几个片段,只对相关的几个片段上锁,同时允许多线程访问其他未上锁的片段。

CopyOnWriteArrayList 允许多个线程以非同步的方式读,当有线程写的时候它会将整个 List 复制一个副本给它。如果在读多写少这种对并发集合有利的条件下使用并发集合,这会比使用同步集合更具有可伸缩性。

并发集合的使用建议

一般不需要多线程的情况,只用到 HashMap、ArrayList,只要真正用到多线程的时候就一定要考虑同步。所以这时候才需要考虑同步集合或并发集合解决方法
1: 给整个集合上添加一个锁

 List< Integer> list=Collections.synchronizedList(new ArrayList<>()); 

解决方法 2:适用于读多写少的场景下

List< Integer> list=new CopyOnWriteArrayList<>();

ConcurrentHashMap 实现原理

ConcurrentHashMap (JDK1.7) 是由 Segment 数组结构和 HashEntry 数组结构组成。 Segment 是一种可重入锁 ReentrantLock,在 ConcurrentHashMap 里扮演锁的角色, HashEntry 则用于存储键值对数据。

一个 ConcurrentHashMap 里包含一个 Segment 数组,Segment 的结构和 HashMap 类似,是一种数组和链表结构, 一个 Segment 里包含一个 HashEntry 数组,每个 HashEntry是一个链表结构的元素, 每个 Segment 守护者一个HashEntry 数组里的元素,当对HashEntry 数组的数据进行修改时,必须首先获得它对应的 Segment 锁

1.7 的具体存储结构
在这里插入图片描述

JDK1.7 版本的 CurrentHashMap 的实现原理
在 JDK1.7 中 ConcurrentHashMap 采用了【数组+Segment 分段锁】的方式实现。

1、Segment(分段锁) ConcurrentHashMap 中的分段锁称为 Segment,它即类似于 HashMap 的结构,即内部拥有一个 Entry 数组,数组中的每个元素又是一个链表,同时又是一个 ReentrantLock(Segment 继承了 ReentrantLock)。

2.内部结构。 ConcurrentHashMap 使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。ConcurrentHashMap 定位一个元素的过程需要进行两次 Hash 操作。第一次 Hash 定位到 Segment,第二次 Hash 定位到元素所在的链表的头部。

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

好处:写操作的时候可以只对元素所在的 Segment 进行加锁即可,不会影响到其他的 Segment,这样,在最理想的情况下,ConcurrentHashMap 可以最高同时支持 Segment 数量大小的写操作(刚好这些写操作都非常平均地分布在所有的 Segment 上)。所以通过这种结构,ConcurrentHashMap 的并发能力可以大大的提高。
在这里插入图片描述

JDK8 中 ConcurrentHashMap 采用了【数组+链表+红黑树】的实现方式来设计,内部大量采用 CAS 操作。

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

Node:保存 key,value 及 key 的 hash 值的数据结构。其中 value 和 next 都用 volatile 修饰,保证并发的可见性。
在 JDK8 中 ConcurrentHashMap 的结构,由于引入了红黑树,使得 ConcurrentHashMap 的实现非常复杂,红黑树是一种性能非常好的二叉查找树,其查找性能为 O(log2N),但是其实现过程也非常复杂,而且可读性也非常差,早期完全采用链表结构时 Map 的查找时间复杂度为 O(N),JDK8 中 ConcurrentHashMap 在链表的长度大于某个阈值的时候会将链表转换成红黑树进一步提高其查找性能。

CAS 是 compare and swap 的缩写,即比较交换。cas 是一种基于锁的操作,而且是乐观锁。

在 java 中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加 version 来获取数据,性能较悲观锁有很大的提高。

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

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

CopyOnWrite 容器

CopyOnWrite 容器即写时复制的容器。通俗的理解是当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行 Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器。这样做的好处是我们可以对 CopyOnWrite 容器进行并发的读,而不需要加锁,因为当前容器不会添加任何元素。
所以 CopyOnWrite 容器也是一种读写分离的思想,读和写不同的容器。

CopyOnWriteArrayList 的实现原理

可以发现在添加的时候是需要加锁的,否则多线程写的时候会 Copy 出 N 个副本出来。读的时候不需要加锁,如果读的时候有多个线程正在向 ArrayList 添加数据,读还是会读到旧的数据,因为写的时候不会锁住旧的 ArrayList

CopyOnWrite 的应用场景

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

注意两点:
减少扩容开销。根据实际需要,初始化 CopyOnWriteMap 的大小,避免写时
CopyOnWriteMap 扩容的开销
使用批量添加。因为每次添加,容器每次都会进行复制,所以减少添加次数,可以减少容器的复制次数。如使用上面代码里的 addBlackList 方法。
不要按照原理进行开发,建议内部使用 CopyOnWriteArrayList

CopyOnWrite 的缺点

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

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

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值