JVM - 锁

本博客为炼数成金JVM教程第九课

目录

  1. 线程安全
  2. 对象头Mark
  3. 偏向锁
  4. 轻量级锁
  5. 自旋锁
  6. 较少锁持有时间
  7. 较小锁粒度
  8. 锁分离
  9. 锁粗化
  10. 锁消除
  11. 无锁

线程安全

多线程网站统计访问人数
使用锁,维护计数器的串行访问和安全性

我们举一个不加锁访问ArrayList的例子

import java.util.ArrayList;
import java.util.List;

public class AddToList implements Runnable {

    private static List<Integer> numberList = new ArrayList<Integer>();

    int startnum = 0;

    public AddToList(int startnumber) {
        this.startnum = startnumber;
    }


    @Override
    public void run() {
        // TODO Auto-generated method stub
        int count = 0;
        while(count < 1000000) {
            numberList.add(startnum);
            startnum+=2;
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new AddToList(0));
        Thread t2 = new Thread(new AddToList(1));
        t1.start();
        t2.start();
        while(t1.isAlive() || t2.isAlive()) {
            Thread.sleep(1);
        }
        System.out.println(numberList.size());
    }

}

按道理应该不会出现异常,且输出的值为2000000.
但结果如下:
这里写图片描述

抛出了一个ArrayIndexOutOfBoundException的异常,且打出的总数为1000269
因为在多线程中,Arraylist不是线程安全的,由于它的空间不够,在做扩展的时候,ArrayList处于一个不可用的状态,而另一个线程在往里放数据,因此就抛出异常

对象头Mark

Mark Word, 对象头的标记,32位
描述对象的hash,锁信息,垃圾回收标记,年龄
指向锁记录的指针
指向monitor的指针
GC标记
偏向锁线程ID

偏向锁

大部分情况是没有竞争的,所以可以通过偏向来提高性能
所谓的偏向,就是偏心,即锁会偏向于当前已经占有锁的线程(即当前这个线程已经拿到锁,那么这个线程试图再次拿到锁,会以最快的方式拿到锁,而不需要通过monitor)
将对象头Mark的标记设置为偏向,并将线程ID写入对象头Mark
只要没有竞争,获取偏向锁的线程,在将来进入同步块,不需要做同步
当其他线程请求相同的锁时,偏向模式结束
-XX:+UseBiasedLocking (默认开启)
在竞争激烈的场合,偏向锁会增加系统负担

import java.util.List;
import java.util.Vector;

public class AddToVector {
    public static List<Integer> numberList = new Vector<Integer>();// Vector 线程安全

    public static void main(String[] args) {
        long begin = System.currentTimeMillis();
        int count = 0;
        int startnum = 0;
        while(count < 1000000) {
            numberList.add(startnum);
            startnum += 2;
            count++;
        }
        long end = System.currentTimeMillis();
        System.out.println(end - begin);
    }
}

运行参数:

-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0

结果:

22

运行参数

-XX:-UseBiasedLocking

结果

32

在本例中,使用偏向锁,可以获得5%以上的性能提升

轻量级锁

先介绍一个概念:BasicObjectLock(JVM的一个锁)
这是一个嵌入在线程栈中的对象

BasicObjectLock组成
BasicObjectLock 由两部分组成,一个是BasicLock,里面存的是displaced_header 可以理解为这是一个对象头。另外一部分是指向持有这个锁的对象的指针

普通的锁处理性能不够理想,轻量级锁是一种快速的锁定方式
如果对象没有被锁定,锁定时,将对象头的Mark指针保存到锁对象中(BasicObjectLock),并将对象头设置为指向锁的指针(在线程栈空间中)

伪代码如下:

lock->set_displaced_header(mark);
 if (mark == (markOop) Atomic::cmpxchg_ptr(lock, obj()->mark_addr(), mark)) {
      TEVENT (slow_enter: release stacklock) ;
      return ;
}

由于Lock位于线程栈中,因此只需要判断对象头的指针所指向的方向是不是在线程栈中。如果是说明线程持有锁。

如果轻量级锁失败,表示存在竞争,升级为重量级锁(常规锁)
在没有竞争的前提下,轻量级锁减少了传统锁使用OS互斥量产生的性能损耗
在竞争激烈时,轻量级锁会做很多额外操作,导致性能下降

自旋锁

当竞争存在时,如果线程可以很快获得锁,那么可以不在OS层挂起线程,让线程做几个空操作(自旋)
JDK1.6中, -XX:+UseSpinning 开启
JDK1.7中,去掉此参数,改为内置实现
如果同步块很长,自旋失败,会降低系统性能(一个线程的同步块很长,在这个同步块待得时间特别长,那么后面的线程的自旋的成功率大大降低,就会降低性能。本来自旋的目的是不需要挂起线程就可以获取锁,用空转指令代替线程挂起和恢复的开销,只要空转指令的成本小于挂起和恢复的开销,那么就是合适的。如果自旋之后还拿不到锁,还是要线程挂起,那么系统性能肯定是会降低的)
如果同步快很短,自旋成功,节省线程挂起切换时间,提升系统性能

偏向锁,轻量级锁,自旋锁的总结

不是Java语言层面的锁优化方式
内置于JVM中获取锁的优化方法和获取锁的步骤
 第一步:偏向锁可用会先尝试偏向锁
 第二步:轻量级锁可用会优先尝试轻量级锁
 第三步:以上都失败,会尝试自旋锁
 第四步: 再失败,尝试普通锁,使用OS互斥在操作系统层挂起

减少锁持有时间

假设有一个同步方法:

public synchronized void syncMethod(){
    othercode1();
    mutextMethod(); // 这个方法需要同步
    othercode2();
}

对于这个方法而言,锁加在对象上面,一旦进入这个方法就需要获取锁,直到方法执行完成释放锁。
如果有些方式没有必要做同步,那么就没有必要放到同步块里面去,我们可以做如下的修改:

public void syncMethod2(){
    othercode1();
    synchronized(this){
        mutextMethod();
    }
    othercode2();
}

可以减少锁的持有时间。在高并发的情况下,锁等待的时间就会减少,自旋的成功率就会增大。

减少锁粒度

将大对象,拆成小对象,大大增加并行度,降低锁竞争
锁竞争降低后,偏向锁,轻量级锁成功率提高,则性能会提高
根据结构进行性能优化

HashMap的同步实现:
Collections.synchroniezdMap(Map<K,V> m), 返回SynchronizedMap对象

public V get(Object key) {
            synchronized (mutex) {return m.get(key);}
        }
public V put(K key, V value) {
            synchronized (mutex) {return m.put(key, value);}
}

对于同步的hash表,get操作和put操作都需要锁住对象。当多个线程做操作是,都需要获取锁。所以一个线程操作时,其余的线程都无法操作。因此竞争相对激烈。

ConcurrentHashMap
存在若干个Segment: Segment<K,V>[] segments
Segment 中维护HashEntry<K,V> (可以理解为一个segment就是一个小的hashmap)
put操作时,先定位到Segement,锁定该Segement,执行put
假设两个线程操作ConcurrentHashMap,分别操作两个Segment,那么两个线程可以同时操作segment,不需要等待锁,竞争就小了很多
在减小锁粒度后,ConcurrentHashMap允许若干个线程同时进入

锁分离

根据功能进行锁分离

例子: ReadWriteLock
ReadWriteLock
在对对象进行读写的时候加锁,在读的时候加读锁,在写的时候加写锁。而不是一棍子就加所有的锁。
如果一个线程拿了读锁,另一个线程仍可以拿到读锁,读写锁允许多个线程同时获得读锁,真正做到多个线程并发读取,不需要做等待,这个时候系统性能提高。
写的时候,对象需要做修改,对象在写的时候状态不可用,所以在写的时候,其余线程不可读,同时其余线程也不可写。
读写锁在读多写少的情况,可以提高性能

读写分离思想可以延伸,只要操作互不影响,锁就可以分离
例子: LinkedBlockingQueue(链表+队列)
LinkedBlockingQueue
对于链表来说有两个操作,一个是从链表中拿出一个元素,如图中拿出A,另一个操作是往列表中增加一个元素,如图中往D后面添加一个元素。这两个操作,一个往链表尾巴上添加元素,一个往列表头上删除元素。这两个操作互不影响。对take只要把A拿出来,把head设置为A的next,对put来讲,在D的next设置为新的元素就可以。在多线程的环境中,既要take又要put,我们可以在take的时候拿take锁,在put的时候拿put锁。

锁粗化

通常情况下,为了保持多线程间的有效并发,会要求每个线程持有锁的时间尽量短,即在使用完公共资源后,应该立即释放锁。只有这样,等待在这个锁上的其他线程才能尽早的获得资源执行任务。但是,凡事都有一个度,如果对同一个锁不停的进行请求,其本身也会消耗系统宝贵的资源,反而不利于性能的优化。

public void demoMethod(){
    synchronized(lock){
        //do sth.
    }
    //做其他不需要的同步的工作,但能很快执行完毕
    synchronized(lock){
        //do sth.
    }
}

前后两次拿了相同的锁,并且频率很高,因为其他的操作很快就完成了,根据锁粗化的思想,我们做如下修改:

public void demoMethod(){
        //整合成一次锁请求
    synchronized(lock){
        //do sth.
        //做其他不需要的同步的工作,但能很快执行完毕
    }
}

这个前提是:不需要同步的操作,是很快就可以完成的

再有一种情况:

for(int i=0;i<CIRCLE;i++){
    synchronized(lock){

    }
}

每一次循环都要获取锁,这是很浪费资源的,我们可以做如下修改

synchronized(lock){
for(int i=0;i<CIRCLE;i++){

    }
}

锁消除

锁消除是JVM当中的一种优化手段,但我们可以通过JVM的参数进行控制

在即时编译时,如果发现不可能被共享的对象,则可以消除这些对象的锁操作

public static void main(String args[]) throws InterruptedException {
    long start = System.currentTimeMillis();
    for (int i = 0; i < 2000000; i++) {
        craeteStringBuffer("JVM", "Diagnosis");
    }
    long bufferCost = System.currentTimeMillis() - start;
    System.out.println("craeteStringBuffer: " + bufferCost + " ms");
}

public static String craeteStringBuffer(String s1, String s2) {
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    return sb.toString();
}

StringBuffer 是线程安全的,所以无意中使用了锁(JVM默认),但sb很明显是不会被其余线程使用的,因此这个锁操作的多余的,可以被消除掉。

运行参数如下:

-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
开启逃逸分析:锁消除基于逃逸分析,锁消除的基本前提是这个变量没有逃逸出当前代码的局部代码块
开启锁消除

结果

createStringBuffer: 187 ms

运行参数如下:

-server -XX:+DoEscapeAnalysis -XX:-EliminateLocks
关闭锁消除

结果

createStringBuffer: 254 ms

无锁

锁是一种悲观的操作,我们预计存在竞争所以加锁
无锁是乐观的操作,认为这次进去不会遇到竞争,如果发生了竞争,再考虑怎么解决问题
无锁的一种实现方式:CAS(Compare And Swap), 一种非阻塞的同步
  CAS(V,E,N) V: 要更新的变量,E一种期望值,希望V当前是多少,N新的值,
  CAS()要做的是把N赋给V,前提是当且仅当V=E,才把N赋给V,最后把V的真实值返回
在应用层面判断多线程的干扰,如果有干扰,则通知线程重试

java.util.concurrent.atomic包使用无锁实现,性能高于一般的有锁操作
例子:

java.util.concurrent.atomic.AtomicInteger
public final int getAndSet(int newValue) {  // 设置新值
    for (;;) {
        int current = get();
        if (compareAndSet(current, newValue)) // 更新成功返回true
            return current;
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值