测试用例 树莓派USB wavedorm EasyCVR javascript iframe paypal proxy electron webkit vue特点 nodejs视频教程 pmp视频教程 jquery触发点击事件 css获取最后一个元素 华为路由器ipv6配置 short几个字节 spark文档 matlab 图像识别 ssr链接解析 excel带格式复制粘贴 python编译环境 java循环语句 java时间转换 java实例方法 java中new java中接口的定义 java获取url linux服务器 linux下载安装 linux系统启动过程 linux镜像安装 js倒计时代码 神龙激活 ps校正倾斜照片 如何给黑白照片上色 selinux关闭 管理文件 模拟按键 js发送http请求
当前位置: 首页 > 学习教程  > 编程语言

java集合的并发问题

2021/3/3 0:00:37 文章标签:

文章目录一、Java集合中的快速失败机制解决并发修改问题的方法二、java并发编程volatile互斥锁sychronized公平锁/非公平锁可重入锁独享锁/共享锁乐观锁/悲观锁偏向锁/轻量级锁/重量级锁自旋锁锁消除synchronized和lock比较线程阻塞的代价三、同步集合和并发集合并发集合的实现…

文章目录

  • 一、Java集合中的快速失败机制
    • 解决并发修改问题的方法
  • 二、java并发编程
    • volatile
    • 互斥锁sychronized
    • 公平锁/非公平锁
    • 可重入锁
    • 独享锁/共享锁
    • 乐观锁/悲观锁
    • 偏向锁/轻量级锁/重量级锁
    • 自旋锁
    • 锁消除
    • synchronized和lock比较
    • 线程阻塞的代价
  • 三、同步集合和并发集合
    • 并发集合的实现原理
    • ConcurrentHashMap实现原理
    • CopyOnWrite容器
    • CopyOnWriteArrayList的实现原理
    • CopyOnWrite的应用场景
    • CopyOnWrite的缺点


一、Java集合中的快速失败机制

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

当线程在遍历集合的同时,有另外线程进行了集合结构的修改,则会引发异常,异常是告知遍历集合的线程当前集合已经发生了改变[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用于设定是否使用公平锁,默认非公平锁

如果已经拥有读锁,因为写锁属于独占锁,所以必须释放读锁后才能申请,如果没有锁去释放锁会报错

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

独享锁/共享锁

独享锁是指该锁一次只能被一个线程所持有。共享锁是指该锁可被多个线程所持有。
对于Java ReentrantLock是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。

读锁的共享锁可保证并发读是非常高效的,多线程中读写\写读\写写的过程是互斥的。

独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。

对于synchronized而言,当然是独享锁。

乐观锁/悲观锁

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

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

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

悲观锁在Java中的使用,就是利用各种锁。

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

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

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

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

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

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

自旋锁

在Java中,自旋锁是指尝试获取锁的线程不会立即阻塞,而是采用循环的方式去尝试获取锁,这样的好处是减少线程上下文切换的消耗,缺点是循环会消耗CPU

自旋锁的本质:执行几个空方法,稍微等一等,也许是一段时间的循环,也许是几行空的汇编指令。

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

锁消除

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

synchronized和lock比较

synchronized和lock都是可重入锁。但是synchronized不是可中断锁,而Lock是可中断锁。

Synchronized底层使用指令码方式或者使用对象头的特定字段来控制锁的,映射成字节码指令就是增加两个指令:monitorenter和monitorexit。当线程执行遇到monitorenter指令时会尝试获取内置锁,如果获取锁则锁计数器+1,如果没有获取锁则阻塞;当遇到monitorexit指令时锁计数器-1,如果计数器为0则释放锁。

Lock底层是CAS乐观锁,依赖AbstractQueuedSynchronizer类,把所有的请求线程构成一个自旋锁队列。而对该队列的操作均通过Lock-Free(CAS)操作。

Synchronized是关键字,内置语言实现,Lock是接口,系统中提供了3种锁实现ReentrantLock和ReentrantReadWriteLock

Synchronized在线程发生异常时会自动释放锁,因此不会发生异常死锁。Lock异常时不会自动释放锁,所以需要在finally中实现释放锁。

Lock是可以中断锁,Synchronized是非中断锁,必须等待线程执行完成释放锁。

Lock可以使用读锁提高多线程并发读的效率。

synchronized优点:实现简单,语义清晰,便于JVM堆栈跟踪,加锁解锁过程由JVM自动控制,提供了多种优化方案,使用更广泛。
缺点:悲观的排他锁,不能进行高级功能

synchronized锁类型
对象锁:使用 synchronized 修饰非静态的方法以及 synchronized(this) 同步代码块使用的锁是对象锁
类锁:使用 synchronized 修饰静态的方法以及 synchronized(class) 同步代码块使用的锁是类锁
私有锁:在类内部声明一个私有属性如private Object lock,在需要加锁的同步块使用 synchronized(lock)

synchronized锁特性
对象锁具有可重入性
当一个线程获得了某个对象的对象锁,则该线程仍然可以调用其他任何需要该对象锁的 synchronized 方法或 synchronized(this) 同步代码块。

当一个线程访问某个对象的一个 synchronized(this) 同步代码块时,其他线程对该对象中所有其它 synchronized(this) 同步代码块的访问将被阻塞,因为访问的是同一个对象锁。

每个类只有一个类锁,但是类可以实例化成对象,因此每一个对象对应一个对象锁。类锁和对象锁不会产生竞争。私有锁和对象锁也不会产生竞争。使用私有锁可以减小锁的细粒度,减少由锁产生的开销。

lock优点:可定时的、可轮询的与可中断的锁获取操作,提供了读写锁、公平锁和非公平锁。缺点:需手动释放锁unlock,不适合JVM进行堆栈跟踪
ReentrantLock 需要显式地进行释放锁。特别是在程序异常时,synchronized 会自动释放锁,而 ReentrantLock 并不会自动释放锁,所以必须在 finally 中进行释放锁
公平性:支持公平锁和非公平锁。默认使用了非公平锁
可重入
可中断:相对于 synchronized,它是可中断的锁,能够对中断作出响应
超时机制:超时后不能获得锁,因此不会造成死锁
ReentrantLock 是很多类的基础,例如ConcurrentHashMap内部使用的Segment[1.7]就是继承ReentrantLock,CopyOnWriteArrayList也使用了ReentrantLock。

在JDK1.5-中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的Lock对象性能更高一些。多线程环境下,synchronized的吞吐量下降的非常严重,而ReentrankLock则能基本保持在同一个比较稳定的水平上。
JDK1.6发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等。导致在JDK1.6上synchronize的性能并不比Lock差。官方也表示也更支持synchronize,在未来的版本中还有优化余地,所以还是提倡在synchronized能实现需求的情况下优先考虑使用synchronized来进行同步。

线程阻塞的代价

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

如果线程状态切换是一个高频操作时,这将会消耗很多CPU处理时间;如果对于那些需要同步的简单的代码块,获取锁挂起操作消耗的时间比用户代码执行的时间还要长,这种同步策略显然非常糟糕的。

synchronized会导致争用不到锁的线程进入阻塞状态,所以说它是java语言中一个重量级的同步操纵,被称为重量级锁,为了缓解上述性能问题,JVM从1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

三、同步集合和并发集合

同步集合类:Hashtable、Vector 方法上有同步约束 (jdk1.0)

同步集合包装类:Collections.synchronizedMap(new HashMap<>())和Collections.synchronizedList(new ArrayList<>()) —使用的是全局锁

并发集合类:ConcurrentHashMap、CopyOnWriteArrayList、CopyOnWriteHashSet

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

并发集合的实现原理

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

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

并发集合的使用建议
一般不需要多线程的情况,只用到HashMap、ArrayList,只要真正用到多线程的时候就一定要考虑同步。所以这时候才需要考虑同步集合或并发集合

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容器。


本文链接: http://www.dtmao.cc/news_show_758629.shtml

附件下载

相关教程

    暂无相关的数据...

共有条评论 网友评论

验证码: 看不清楚?