0
点赞
收藏
分享

微信扫一扫

JAVA中的锁[研究小结]


JAVA中的锁[研究小结]_开发语言

JAVA中的锁

  • ​​什么是java中的锁?​​
  • ​​自旋锁​​
  • ​​自旋锁的开启​​
  • ​​Synchronized锁​​
  • ​​偏向锁​​
  • ​​jvm开启/关闭偏向锁​​
  • ​​轻量级锁​​
  • ​​轻量级锁的释放​​
  • ​​synchronized的执行过程总结​​
  • ​​锁优化​​
  • ​​锁细化​​
  • ​​锁粗化​​
  • ​​缓存行的伪共享​​

看了那么多关于锁的文章,对于java中的锁还是单独整理一下吧,以便后来复习用;另一个整理一遍加深理解与印象吧!

本文章仅供粉丝参考,如有不当之处还请指出,以作改正

什么是java中的锁?

一个不太那啥的例子:

java中的锁就像是一个看厕所的大爷,有人进去方便了,他就把门上锁了,即别的人就不能进去方便了;

悲观锁----->>>
话说这位大爷时不时有些精分---->>
有时候认为上厕所的人会很多,所以每次都会提前将厕所给锁起来,别人在来的时候就得从大爷哪里拿到锁才能进去方便;

那么问题来了,上厕所这件事比较急,如果来一个人就需要到大爷这里拿到锁才能进去方便----->>会产生额外的系统开销,(得把大爷累死),另一个如果有人来大号的,那么…

乐观锁---->>

又有时候这位大爷比较乐观,认为来上厕所的人会很少,于是就不上锁,但是在来人上厕所的时候会先看一下厕所有没有人,如果有人则让来上厕所的那个人排队等一下;

对应到操作系统上----悲观锁

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

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

JAVA中的锁[研究小结]_自旋锁_02


为了缓解大爷的工作压力,在JDK1.5开始,引入了轻量锁与偏向锁,默认启用了自旋锁,他们都属于乐观锁。

我们来看一下大爷是具体是怎么工作的-----即怎么做好如厕的标记的;

mark word是java中对象那个数据结构的一部分,其数据长度为32位和64位(这个跟平台有关如果是32位系统平台则是32位长度),它们最后两位都是标记了所得状态,

状态 标志位 存储内容
未锁定 01 对象哈希码、对象分代年龄
轻量级锁定 00 指向锁记录的指针
膨胀(重量级锁定) 10 执行重量级锁定的指针
GC标记 11 空(不需要记录信息)
可偏向 01 偏向线程ID、偏向时间戳、对象分代年龄

32位虚拟机在不同状态下markword结构如下图所示:

JAVA中的锁[研究小结]_自旋锁_03


JAVA中的锁[研究小结]_java_04

自旋锁

如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。

但是线程自旋是需要消耗CPU的,即让cpu在做无用功,所以不能让他一直这么自旋下去,所以要有一个时间限制,如果超过这个时间限制,就会停止自旋进入阻塞队列中;

自旋锁的优点:

自旋锁尽可能的减少线程的阻塞,这对于锁的竞争不激烈,且占用锁时间非常短的代码块来说性能能大幅度的提升,因为自旋的消耗会小于线程阻塞挂起再唤醒的操作的消耗,这些操作会导致线程发生两次上下文切换!

如果锁竞争激烈,那么就不适合用自旋锁,大量线程自旋会造成cpu资源的浪费;

JVM对于自旋周期的选择,jdk1.5这个限度是一定的写死的,在1.6引入了适应性自旋锁,适应性自旋锁意味着自旋的时间不在是固定的了,而是由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时JVM还针对当前CPU的负荷情况做了较多的优化;

  1. 如果**平均负载小于CPUs(cpu核心数)**则一直自旋
  2. 如果有超过(CPUs/2)个线程正在自旋,则后来线程直接阻塞
  3. 如果正在自旋的线程发现Owner发生了变化延迟自旋时间(自旋计数)或进入阻塞
  4. 如果CPU处于节电模式则停止自旋
  5. 自旋时间的最坏情况是CPU的存储延迟(CPU A存储了一个数据,到CPU B得知这个数据直接的时间差)
  6. 自旋时会适当放弃线程优先级之间的差异

自旋锁的开启

JDK1.6中-XX:+UseSpinning开启;

-XX:PreBlockSpin=10 为自旋次数;

JDK1.7后,去掉此参数,由jvm控制

Synchronized锁

它可以把任意非null的对象当作锁;
1,作用于方法上时锁住得得是当前对象
2,作用于静态方法上锁住的是class实例,也可理解为class文件(因为jvm在加载字节码信息时会自动生成calss对象)
3.作用于代码块时,锁住的是class实例;

synchronized实现

JAVA中的锁[研究小结]_java_05


首先线程会进入竞争队列,有机会获得锁(候选竞争线程)的会被放入entrylist ,(每次从队列的尾部取出一个数据,对于调用wait()方法的会被放入waitset)之后展开对锁的竞争即会进入ondeck里进行锁竞争 ,拿到锁的会执行执行完毕后会释放锁;

处于ContentionList、EntryList、WaitSet中的线程都处于阻塞状态,该阻塞是由操作系统来完成的

Synchronized是非公平锁。

偏向锁

偏向锁会偏向于第一个访问它的线程,如果线程竞争很小,基本只有一个线程来访问,则线程不需要动用同步锁,而是加一个偏向锁;

如果锁竞争加剧了,偏向锁会被挂起,将锁恢复为轻量级锁;

偏向锁的实现是通过mark word,将锁的标识设置为01----即偏向锁状态;

偏向锁的原理:

1,访问mark word中的锁标记为,如果是01则为偏向锁;

2,如果是偏向状态,则判断线程ID是否指向该线程,如果是执行代码,如果不是,先尝试通过CAS来竞争锁,竞争成功则将线程id改为当前线程的,如果竞争失败,则进行锁升级(即撤销偏向锁,会出发jvm的停顿,一般间隔时间很短感受不到)

JAVA中的锁[研究小结]_缓存_06

jvm开启/关闭偏向锁

  • 开启偏向锁:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
  • 关闭偏向锁:-XX:-UseBiasedLocking

轻量级锁

轻量级锁是由偏向锁升级而来,

偏向锁升级的过程中会拷贝存储锁对象的mark word,称为lock record空间;

拷贝之后虚拟机尝试使用cas修改mark word中的锁标记为00,同时将markword中的指向lockrecord的指针指向lockrecord(也是CAS操作),如果修改成功,则锁升级成功,如果失败了,会先检查一下对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标记位变为10,即升级成重量级锁,当前线程便尝试使用自旋来获取锁,后面等待的线程会进入阻塞状态;

JAVA中的锁[研究小结]_自旋锁_07

轻量级锁的释放

在轻量锁的释放时,如果发现在线程持有锁期间其他线程都来尝试获取锁,那么锁会继续升级为重量级锁;

偏向锁是在无锁争用的情况下使用的,也就是同步开在当前线程没有执行完之前,没有其它线程会执行该同步块,一旦有了第二个线程的争用,偏向锁就会升级为轻量级锁,如果轻量级锁自旋到达阈值后,没有获取到锁,就会升级为重量级锁;

在目前高并发的大环境下,一般是关闭偏向锁的;

synchronized的执行过程总结

  1. 检测Mark Word里面是不是当前线程的ID,如果是,表示当前线程处于偏向锁
  2. 如果不是,则使用CAS将当前线程的ID替换Mard Word,如果成功则表示当前线程获得偏向锁,置偏向标志位1
  3. 如果失败,则说明发生竞争,撤销偏向锁,进而升级为轻量级锁。
  4. 当前线程使用CAS将对象头的Mark Word替换为锁记录指针,如果成功,当前线程获得锁
  5. 如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
  6. 如果自旋成功则依然处于轻量级状态。
  7. 如果自旋失败,则升级为重量级锁。

锁优化

锁细化

减少锁粒度

在curenthashmap中有一个静态类Segment,该类继承了 ReentrantLock ;
每个Segment就是个可重入锁,每个Segment 有一个HashEntry< K,V >数组用来存放数据,put操作时,先确定往哪个Segment放数据,只需要锁定这个Segment,执行put,其它的Segment不会被锁定;所以数组中有多少个Segment就允许同一时刻多少个线程存放数据,这样增加了并发能力。
curenthashmap

static class Segment<K,V> extends ReentrantLock implements Serializable {
private static final long serialVersionUID = 2249069246763182397L;
final float loadFactor;
Segment(float lf) { this.loadFactor = lf; }
}

LongAdder

@jdk.internal.vm.annotation.Contended static final class Cell {
volatile long value;
Cell(long x) { value = x; }
final boolean cas(long cmp, long val) {
return VALUE.compareAndSet(this, cmp, val);
}
final void reset() {
VALUE.setVolatile(this, 0L);
}
final void reset(long identity) {
VALUE.setVolatile(this, identity);
}
final long getAndSet(long val) {
return (long)VALUE.getAndSet(this, val);
}

LinkedBlockingQueue

LinkedBlockingQueue也体现了这样的思想,在队列头入队,在队列尾出队,入队和出队使用不同的锁,相对于LinkedBlockingArray只有一个锁效率要高;
LinkedBlockingQueue中源码

private transient Node<E> last;

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

拆锁的粒度不能无限拆,最多可以将一个锁拆为当前cpu数量个锁即可;

与锁细化对应的是锁粗化

锁粗化

增加锁的粒度

大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度;

在以下场景下需要粗化锁的粒度:

假如有一个循环,循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的;

使用读写锁
ReentrantReadWriteLock 是一个读写锁,读操作加读锁,可以并发读,写操作使用写锁,只能单线程写;

读写分离
CopyOnWriteArrayList 、

public void add(int index, E element) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
if (index > len || index < 0)
throw new IndexOutOfBoundsException(outOfBounds(index, len));
Object[] newElements;
int numMoved = len - index;
if (numMoved == 0)
newElements = Arrays.copyOf(es, len + 1);
else {
newElements = new Object[len + 1];
System.arraycopy(es, 0, newElements, 0, index);
System.arraycopy(es, index, newElements, index + 1,
numMoved);
}
newElements[index] = element;
setArray(newElements);
}
}

CopyOnWriteArraySet

private boolean addIfAbsent(E e, Object[] snapshot) {
synchronized (lock) {
Object[] current = getArray();
int len = current.length;
if (snapshot != current) {
// Optimize for lost race to another addXXX operation
int common = Math.min(snapshot.length, len);
for (int i = 0; i < common; i++)
if (current[i] != snapshot[i]
&& Objects.equals(e, current[i]))
return false;
if (indexOfRange(e, current, common, len) >= 0)
return false;
}
Object[] newElements = Arrays.copyOf(current, len + 1);
newElements[len] = e;
setArray(newElements);
return true;
}
}

写时复制的容器

CopyOnWrite并发容器用于读多写少的并发场景,因为,读的时候没有锁,但是对其进行更改的时候是会加锁的,否则会导致多个线程同时复制出多个副本,各自修改各自的;

缓存行的伪共享

即每个cpu都有自己的缓存,cpu读取到的数据时是以缓存行的方式进行读取,这就会导致了在一个缓存行里的不需要的对象被加上了同步锁,其他线程在处理这些对象时要等该cpu处理完之后把锁释放了才能处理,这会大大影响整个程序的处理速度;

  1. 在jdk1.7之前会 将需要独占缓存行的变量前后添加一组long类型的变量,依靠这些无意义的数组的填充做到一个变量自己独占一个缓存行;
  2. 在jdk1.7因为jvm会将这些没有用到的变量优化掉,所以采用继承一个声明了好多long变量的类的方式来实现;
  3. 在jdk1.8中通过添加sun.misc.Contended注解来解决这个问题,若要使该注解有效必须在jvm中添加以下参数:
    -XX:-RestrictContended

sun.misc.Contended注解会在变量前面添加128字节的padding将当前变量与其他变量进行隔离;


举报

相关推荐

0 条评论