java并发编程学习三

java并发编程学习二

java并发编程学习三

我们继续跟进并发编程的相关技术。

单例:懒汉式创建


当CPU对创建single指令进行重排序的时候,开辟了一块地址给single,但是还没有对single的具体数据进行赋值,这个时候single的对象的引用有了,但是对象的域还没有进行赋值。

可以把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步

volatile变量自身具有下列特性:

可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。

原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。

在这里插入图片描述

当前线程对count变量读取之后进行一系列的运算,另外的线程刚好刷新了count,当前线程不会重新读取再运行一遍,所以只能保证可见性不能保证原子性。

volatile 写的内存语义如下:
当写一个volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新
到主内存。
volatile 读的内存语义如下:
当读一个volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下
来将从主内存中读取共享变量。


在这里插入图片描述

看起来跟上面的volatile好像是一样的,其实就是差不多一个意思,区别就是一个线程拿了锁,没有另外的线程能同时拿到这个锁对象。其他的如执行需要的变量都会置为无效从新从主内存读取和执行完立马刷新到主内存是一个样的,所以说可见性的意思就是指锁和volatile。

synchronized 底层实现原理
synchronized (this)原理:涉及两条指令:monitorenter,monitorexit;
再说同步方法,从同步方法反编译的结果来看,方法的同步并没有通过指令
monitorenter 和monitorexit 来实现,相对于普通方法,其常量池中多了
ACC_SYNCHRONIZED 标示符。
JVM 就是根据该标示符来实现方法的同步的:当方法被调用时,调用指令将
会检查方法的ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线
程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放
monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor 对象。

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

偏向锁
引入背景:大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多 次获得,为了让线程获得锁的代价更低而引入了偏向锁,减少不必要的 CAS 操作。 偏向锁,顾名思义,它会偏向于第一个访问锁的线程,如果在运行过程中, 同步锁只有一个线程访问,不存在多线程争用的情况,则线程是不需要触发同步 的,减少加锁/解锁的一些 CAS 操作(比如等待队列的一些 CAS 操作),这种 情况下,就会给线程加一个偏向锁。 如果在运行过程中,遇到了其他线程抢占 锁,则持有偏向锁的线程会被挂起,JVM 会消除它身上的偏向锁,将锁恢复到标 准的轻量级锁。它通过消除资源无竞争情况下的同步原语,进一步提高了程序的 运行性能。

偏向锁的释放:
偏向锁只有遇到其他线程尝试竞争偏向 锁时,持有偏向锁的线程才会释放偏向锁,线程不会主动去释放偏向锁。偏向锁 的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首 先暂停拥有偏向锁的线程,判断锁对象是否处于被锁定状态,撤销偏向锁后恢复 到未锁定(标志位为“01”)或轻量级锁(标志位为“00”)的状态。 偏向锁的适用场景 始终只有一个线程在执行同步块,在它没有执行完释放锁之前,没有其它线程去 执行同步块,在锁无竞争的情况下使用,一旦有了竞争就升级为轻量级锁,升级 为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致 stop the word 操 作;在有锁的竞争时,偏向锁会多做很多额外操作,尤其是撤销偏向所的时候会导致 进入安全点,安全点会导致 stw,导致性能下降,这种情况下应当禁用。

轻量级锁
轻量级锁是由偏向锁升级来的,偏向锁运行在一个线程进入同步块的情况下,当 第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; 轻量级锁的加锁过程: 在代码进入同步块的时候,如果同步对象锁状态为无锁状态且不允许进行偏向 (锁标志位为“01”状态,是否为偏向锁为“0”),虚拟机首先将在当前线程 的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的 Mark Word 的拷贝,官方称之为 Displaced Mark Word。

拷贝成功后,虚拟机将使用 CAS 操作尝试将对象的 Mark Word 更新为指向 Lock Record 的指针,并将 Lock record 里的 owner 指针指向 object mark word。 如果这个更新动作成功了,那么这个线程就拥有了该对象的锁,并且对象 Mark Word 的锁标志位设置为“00”,即表示此对象处于轻量级锁定状态 如果这个更新操作失败了,虚拟机首先会检查对象的 Mark Word 是否指向当前 线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进 入同步块继续执行。否则说明多个线程竞争锁,当竞争线程尝试占用轻量级锁失 败多次之后,轻量级锁就会膨胀为重量级锁,重量级线程指针指向竞争线程,竞 争线程也会阻塞,等待轻量级线程释放锁后唤醒他。锁标志的状态值变为“10”, Mark Word 中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也 要进入阻塞状态。

不同等级锁的比较

偏向锁,加锁和解锁不需要额外的消耗,只有一个线程的同步场景。

轻量级锁:自旋,会额外增加CPU的消耗,但是响应快。

重量级锁:阻塞,响应时间相对较慢。

其实了解AQS的原理流程就非常明白了,这也是最新的1.8之后为什么底层都用sync,因为优化之后这部分代码都变成在Sync里面了,不需要自己写逻辑了。

JVM 对于自旋次数的选择,jdk1.5 默认为 10 次,在 1.6 引入了适应性自旋锁, 适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的 自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是 最佳的一个时间。 JDK1.6 中-XX:+UseSpinning 开启自旋锁; JDK1.7 后,去掉此参数,由 jvm 控 制;

JDK 对锁的更多优化措施
逃逸分析
如果证明一个对象不会逃逸方法外或者线程外,则可针对此变量进行优化:
同步消除synchronization Elimination,如果一个对象不会逃逸出线程,则对此变
量的同步措施可消除。
锁消除和粗化
锁消除:虚拟机的运行时编译器在运行时如果检测到一些要求同步的代码上不可
能发生共享数据竞争,则会去掉这些锁。
锁粗化:将临近的代码块用同一个锁合并起来。
消除无意义的锁获取和释放,可以提高程序运行性能。

适用于高并发,底层计算还是AtomicLong,将一个long类型变量转成一个cell64位数组,将一个锁变成分布式锁,跟comcurrentHashMap一样,最后再将数组相加,它的数量不是实时的,没加锁。

AtomicLong 中有个内部变量 value 保存着实际的 long 值,所有的操作都是 针对该变量进行。也就是说,高并发环境下,value 变量其实是一个热点,也就 是 N 个线程竞争一个热点。


LongAdder 的基本思路就是分散热点,将 value 值分散到一个数组中,不同 线程会命中到数组的不同槽中,各个线程只对自己槽中的那个值进行 CAS 操作,
这样热点就被分散了,冲突的概率就小很多。如果要获取真正的 long 值,只要 将各个槽中的变量值累加返回。 这种做法和 ConcurrentHashMap 中的“分段锁”其实就是类似的思路。

但是 AtomicLong 提供的功能其实更丰富,尤其是 addAndGet、 decrementAndGet、compareAndSet 这些方法。 addAndGet、decrementAndGet 除了单纯的做自增自减外,还可以立即获取 增减后的值,而 LongAdder 则需要做同步控制才能精确获取增减后的值。如果业 务需求需要精确的控制计数,做计数比较,AtomicLong 也更合适。 总之,低并发、一般的业务场景下 AtomicLong 是足够了。如果并发量很多, 存在大量写多读少的情况,那 LongAdder 可能更合适。适合的才是最好的,如果 真出现了需要考虑到底用 AtomicLong 好还是 LongAdder 的业务场景,那么这样 的讨论是没有意义的,因为这种情况下要么进行性能测试,以准确评估在当前业 务场景下两者的性能,要么换个思路寻求其它解决方案。

读锁不会阻塞写锁,数据改变了怎么办,重新读。
当然该显示锁业提供了一个悲观锁的设置,底层还是CLH队列,还是AQS实现。
引入了一个时间计算机制,当写锁进入的时候会+1,读锁进来的时候拿到当前的时间值(int类型),判断一下是否有变化,可以看做是stage,这时要不悲观锁进入阻塞,要不自旋再次尝试刚才的操作,直到成功为止。如果是读写锁比例是1000:1,那么写锁会被饿死,但是用了这个显示锁就没有问题。

CompletionStage,可以获取当前阶段状态,CompletableFuture可以将当前线程的状态交给下一个,组成一种流式的调用链,另外一种是把它当做一个future使用。总共有五十多个方法,这里就不详细介绍了。

英国外汇交易公司LMAX开发的一个高性能队列,是一个高性能的线程间异步通信的框架,即在同一个JVM进程中的多线程间消息传递,它不是分布式队列。基于Disruptor开发的系统单线程能支撑每秒600万订单。
应用Disruptor的知名项目有如下的一些:Storm, Camel, Log4j2,还有目前的美团点评技术团队

传统队列:在JDK 中,Java 内部的队列BlockQueue 的各种实现,仔细分析可以得知,
队列的底层数据结构一般分成三种:数组、链表和堆,堆这里是为了实现带有优
先级特性的队列暂且不考虑。
在稳定性和性能要求特别高的系统中,为了防止生产者速度过快,导致内存
溢出,只能选择有界队列;同时,为了减少Java 的垃圾回收对系统性能的影响,
会尽量选择Array 格式的数据结构。这样筛选下来,符合条件的队列就只有
ArrayBlockingQueue。但是ArrayBlockingQueue 是通过加锁的方式保证线程安全,
而且ArrayBlockingQueue 还存在伪共享问题,这两个问题严重影响了性能。
ArrayBlockingQueue 的这个伪共享问题存在于哪里呢,分析下核心的部分源
码,其中最核心的三个成员变量为

是在ArrayBlockingQueue 的核心enqueue 和dequeue 方法中经常会用到的,这三
个变量很容易放到同一个缓存行中,进而产生伪共享问题。
在这里插入图片描述

高性能的原理:引入环形的数组结构:数组元素不会被回收,避免频繁的GC,
无锁的设计:采用CAS 无锁方式,保证线程的安全性
属性填充:通过添加额外的无用信息,避免伪共享问题
环形数组结构是整个Disruptor 的核心所在。

在CPU中,多核心对主内存的读取是一次64字节,我们称之为缓存行,当这一行64个字节的内容被一个核心锁读取的时候,其他的核心是不能读取的,要等待,虽然说数据是共享的,但是实际上是伪共享。

首先因为是数组,所以要比链表快,而且根据我们对上面缓存行的解释知道,
数组中的一个元素加载,相邻的数组元素也是会被预加载的,因此在这样的结构
中,cpu 无需时不时去主存加载数组中的下一个元素。而且,你可以为数组预先
分配内存,使得数组对象一直存在(除非程序终止)。这就意味着不需要花大量
的时间用于垃圾回收。此外,不像链表那样,需要为每一个添加到其上面的对象
创造节点对象—对应的,当删除节点时,需要执行相应的内存清理操作。环形数
组中的元素采用覆盖方式,避免了jvm 的GC。
其次结构作为环形,数组的大小为2 的n 次方,这样元素定位可以通过位运
算效率会更高,这个跟一致性哈希中的环形策略有点像。在disruptor 中,这个
牛逼的环形结构就是RingBuffer,既然是数组,那么就有大小,而且这个大小必
须是2 的n 次方其实质只是一个普通的数组,只是当放置数据填充满队列(即到达2^n-1 位置)之后,再填充数据,就会从0 开始,覆盖之前的数据,于是就相当于一个环。
每个生产者首先通过CAS 竞争获取可以写的空间,然后再进行慢慢往里放数
据,如果正好这个时候消费者要消费数据,那么每个消费者都需要获取最大可消
费的下标。同时,Disruptor 不像传统的队列,分为一个队头指针和一个队尾指针,而
是只有一个角标(上图的seq),它属于一个volatile 变量,同时也是我们能够
不用锁操作就能实现Disruptor 的原因之一,而且通过缓存行补充,避免伪共享
问题。该指针是通过一直自增的方式来获取下一个可写或者可读数据。

并发编程里面还有一个知识点是CPU数据的指令的重组,解释起来非常麻烦,还有类似Lambda表达式这些,有需要的小伙伴们可以自行去了解一下。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值