java中的锁机制

推荐阅读: java锁与线程的那些事
要学习java中锁的机制,首先要了解java对象头。因为锁机制的实现依赖于java的对象头。
那什么是java对象头呢?
当你创建一个对象时,该对象在内存中的存储布局为:
在这里插入图片描述
  对象头含有三部分:Mark Word(存储对象自身运行时数据)、Class Metadata Address(存储类元数据的指针)、Array length(数组长度,只有数组类型才有)。
  其中Mark Word和类型指针就被称为对象头。MarkWord中存放的东西下面详细介绍,类型指针里存放着一个地址,指向该对象是哪个类创建的对象。
  Mark Word的大小为8个字节,类型指针的大小为4个字节。实例数据和对齐位的大小不一定。对齐位主要是要让该对象大小要被8整除,如果不能被整除就补位。
如何查看对象各个区域所占的大小呢?导入如下依赖并使用ClassLayer类来输出。

<dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.9</version>
</dependency>
输出语句:
System.out.println(ClassLayout.parseInstance(对象名).toPrintable());

举个栗子:

public class LayoutTest {
    public static class T{
        int i = 0;
    }

    public static void main(String[] args) {
        T t = new T();
        System.out.println(ClassLayout.parseInstance(t).toPrintable());
    }
}

输出如下:
在这里插入图片描述
表示该对象大小为16个字节。为什么是16bytes呢?
首先Mark Word占8个字节,class pointer占4个字节,数据类型为int占4个字节,总共16个字节,可以被8整除所以对齐位没有补位。
第二个栗子:

public class LayoutTest {
    public static class T{
        String s = "abcdefghijklmn";

    }

    public static void main(String[] args) {
        T t = new T();
        System.out.println(ClassLayout.parseInstance(t).toPrintable());
    }
}

在这里插入图片描述
嗯?这字符串s中有这么长一串怎么还是16个字节呢?
因为此处的s其实是一个指针,该指针指向字符串常量池的这个字符串,这个字符串不在这个对象中创建,所以还是12+4+4=16bytes。因为s是指针class pointer也是指针所以都是4个字节。

然后大的要来了。

Mark Word

Mark Word里默认存储对象的HashCode、分代年龄、是否偏向锁以及锁标记位。32位JVM的Mark Word的默认存储结构如下:
在这里插入图片描述
Synchronized锁的状态被分为4种:无锁,偏向锁,轻量级锁,重量级锁
  注意锁标志汇总没有出现自旋锁,自旋锁仅仅是锁可能存在的一种状态,是暂时性的没有官方的标志。
  无锁和偏向锁标志位都是01,只是偏向锁时偏向模式会被置为1,。锁可以升级但是不可以降级,意味偏向锁升级成轻量级锁后不能降级称为偏向锁。

无锁

  无锁是001状态,偏向标志0,锁标志01;
  更准确来说,001状态应该是无锁不可偏,因为还有一种101状态是无锁可偏(又叫匿名偏向)。无锁不可偏状态下遇到同步会直接升级成轻量级锁,而不会变成偏向锁。只有在无锁可偏101状态下才能变成偏向锁。101状态下线程ID部分全部为0,意味着没有线程实际获得偏向锁。

偏向锁

在jvm启动的前4秒(默认是4000ms)创建的对象都是无锁状态的(001),当遇到锁竞争时会直接升级为轻量级锁。偏向锁是延时初始化的,在4s后创建的对象它的对象头都为匿名偏向样式,只有对象头为匿名偏向样式才能进入偏向锁模式。
匿名偏向状态:锁对象mark word标志位为101,且存储的线程id为空时的状态(即锁对象为偏向锁,且没有线程偏向于这个锁对象)。
  大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
  这个锁永远会偏向于获得它的线程,线程第二次到达同步代码块时,会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁。如果自始至终使用锁的线程只有一个,很明显偏向锁几乎没有额外开销,性能极高。
  如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。一旦有第二个线程加入锁竞争,偏向锁就升级为轻量级锁(自旋锁)。升级为轻量级锁的时候需要撤销偏向锁,撤销偏向锁的时候会导致STW(stop the word)操作;

锁竞争:如果多个线程轮流获取一个锁,但是每次获取锁的时候都很顺利,没有发生阻塞,那么就不存在锁竞争。
只有当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,这才发生了锁竞争。

优点:在只有单一线程访问对象的时候,偏向锁机会没有影响。只有第一次需要cas操作替换,随后的只要比较线程ID即可,比较方便。
缺点:多个线程访问时会出现竞争,一系列分析比较耗费时间。而且偏向锁存放线程ID和Epoch后,对象头中不存在hash值,如果程序需要hash值会导致偏向锁退出。当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态时,收到需要计算其一致性哈希码时,偏向状态会立即撤销,并且锁膨胀为重量级锁
一致性哈希码即:object中的hashCode()方法。当调用过这个方法后,无法再进入偏向锁状态了。
如果对象需要调用Object方法,会启用对象的minoter内置锁,此时会直接有偏向锁升级为重量级锁。

偏向锁的获取

  1)判断锁对象是否是偏向锁(即锁标志位为01,偏向锁位为1),若为偏向锁状态执行2)。
  2)判断锁对象的线程ID是否为当前线程的ID,如果是则说明已经获取到锁,执行代码块;否则执行3)。
  3)当前线程使用CAS更新锁对象的线程ID为当前线程ID。如果成功,获取到锁;否则执行4)
  4)当到达全局安全点,当前线程根据Mark Word中的线程ID通知持有锁的线程挂起,将锁对象Mark Word中的锁对象指针指向当前堆栈中最近的一个锁记录,偏向锁升级为轻量级锁,恢复被挂起的线程。

偏向锁的释放

偏向锁采用一种等到竞争出现时才释放锁的机制。当其他线程尝试竞争偏向锁时,当前线程才会释放偏向锁,否则线程不会主动去释放偏向锁。偏向锁的撤销需要等待全局安全点。
  1)首先暂停持有偏向锁的线程。
  2)撤销偏向锁,恢复到无锁状态或轻量级锁状态。

轻量级锁(自旋锁)

  当一个线程持有轻量级锁时,其他线程会通过自旋的形式尝试获取锁,不会阻塞,提高性能。
  轻量级锁:轻量级锁的原理非常简单,如果持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。
设计初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统的互斥量产生的性能消耗。

轻量级锁的获取

  1)判断是否处于无锁状态,若是,则JVM在当前线程的栈帧中创建锁记录(Lock Record)空间,用于存放锁对象中的Mark Word的拷贝,官方称为Displaced Mark Word;否则执行步骤3)。
   2)当前线程尝试利用CAS将锁对象的Mark Word更新为指向锁记录的指针。如果更新成功意味着获取到锁,将锁标志位置为00,执行同步代码块;如果更新失败,执行步骤3)。
  3)判断锁对象的Mark Word是否指向当前线程的栈帧,若是说明当前线程已经获取了锁,执行同步代码,否则说明其他线程已经获取了该锁对象,执行步骤4)。
  4)当前线程尝试使用自旋来获取锁,自旋期间会不断的执行步骤1),直到获取到锁或自旋结束。因为自旋锁会消耗CPU,所以不能无限的自旋。如果自旋期间获取到锁(其他线程释放锁),执行同步块;否则锁膨胀为重量级锁,当前线程阻塞,等待持有锁的线程释放锁时的唤醒。

轻量级锁的释放

  1)从当前线程的栈帧中取出Displaced Mark Word存储的锁记录的内容。
  2)当前线程尝试使用CAS将锁记录内容更新到锁对象中的Mark Word中。如果更新成功,则释放锁成功,将锁标志位置为01无锁状态;否则,执行3)。
  3)CAS更新失败,说明有其他线程尝试获取锁。需要释放锁并同时唤醒等待的线程。

重量级锁

  在轻量级锁的状态下,线程都会进行自旋等待,尝试获取锁。但是长时间自旋非常消耗性能,不能让线程无限制的自旋下去,如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁。
  当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起,等待将来被唤醒。在JDK1.6之前,synchronized直接加重量级锁,很明显现在得到了很好的优化。
  重量级锁的特点:其他线程试图获取锁时,都会被阻塞,只有持有锁的线程释放锁之后才会唤醒这些线程。

几种锁的比较

优点缺点使用场景
偏向锁加锁和解锁不需要额外的消耗,和执行非同步方法比仅存在纳秒级的差距。如果线程间存在锁竞争,会带来额外的锁撤销的消耗。适用于只有一个线程访问同步块场景。
轻量级锁竞争的线程不会阻塞,提高了程序的响应速度。如果始终得不到锁竞争的线程使用自旋会消耗CPU。追求响应时间。同步块执行速度非常快。
重量级锁线程竞争不使用自旋,不会消耗CPU线程阻塞,线程上下文切换耗费资源大,响应时间缓慢追求吞吐量。同步块执行速度较长。

锁消除

当jvm发现当前锁对象中的方法,无法逃逸出去被其他线程看到,即该段同步代码根本不可能存在竞争,那么就认为该段代码是线程私有的,无须在进行同步加锁。


public String method(String s1,String s2,String s3){
	return s1+s2+s3;
}
在jdk1.5之前,字符串拼接底层要用到StringBuffer类,1.5以后用的StringBuilder类,假如此时是jdk1.4版本,则上述代码变成下面这样

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

上面这段代码调用了StringBuffer类,该类是线程安全的,即该类的所有方法都加了synchronized关键字,但是它的动态作用域被限制在method方法内部,也就是sb的所有引用永远不会逃逸到该方法外面,虽然这里加了锁,但是可以被安全的消除掉。在执行时仍会加锁,但是被编译以后,这段代码会忽略掉所有的同步直接执行

锁粗化

假如一系列连续的操作都对同一个对象频繁的加锁解锁时,如在循环体中加锁。频繁进行互斥操作会导致不必要的性能损耗。

for(int i = 0;i < 10;i++){
	synchronized(obj){
		n++;	
	}
}
虚拟机会把上述的锁加到for循环外
synchronized(obj){
	for(int i = 0;i < 10;i++){
		n++;
	}
}

可重入锁和不可重入锁

可重入锁:又叫递归锁,指的是可重复可递归调用的锁,在外层使用锁之后,在内层仍然可以使用,并且不发生死锁,这样的锁就叫做可重入锁。(前提,锁对象是同一个对象),不会因为之前已经获取过还没释放而阻塞。可重入锁的工作原理很简单,就是用一个计数器来记录锁被获取的次数,获取锁一次计数器+1,释放锁一次计数器-1,当计数器为0时,表示锁可用。
synchronized 和 ReentrantLock 都是可重入锁。
不可重入锁:与可重入锁相反,不可重入锁不可递归调用,递归调用就发生死锁

public class{
	Lock lock = new Lock();
	public void method1(){
		lock.lock();
		method2();
		lock.unlock();
	}
	public void mehod2(){
		lock.lock();
		lock.unlock();
	}
}

公平锁和非公平锁

公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,永远都是队列的第一位才能得到锁。(需要cpu去控制将资源分配给某个线程,导致cpu吞吐率降低)
非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。(cpu不管谁抢到了资源,谁抢到就是谁的)

CAS

   CAS(compare and swap),比较并交换,可以解决多线程并行情况下使用锁造成性能损耗的一种机制.CAS 操作包含三个操作数—内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。
  通俗来说就是: 比较当前工作内存中的值和主内存中的值,如果相同则执行规定操作,否则继续比较直到主内存和工作内存中的值一致为止.(自旋)
CAS是一条CPU的原子指令,不会造成所谓的数据不一致问题。

CAS存在的问题:
1、ABA问题。因为CAS需要在操作值的时候检查下值有没有发生变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。ABA问题的解决思路就是使用版本号。在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A - 2B-3A。
2、循环时间长开销大。自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率。

CAS用来实现无锁编程

举例:让三个线程把num从0加到1000

使用CAS无锁方法:

public class CAS {
    static AtomicInteger num = new AtomicInteger(0);
    public static void main(String[] args) {
        for(int i = 0;i<3;i++){
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    while(num.get() < 1000){
                        System.out.println("Thread name:" + Thread.currentThread().getName() + ":" + num.incrementAndGet());
                    }
                }
            });
            t.start();
        }
    }
}

输出
在这里插入图片描述
AtomicInteger类:
①.支持原子操作的Integer类
②.主要用于在高并发环境下的高效程序处理。使用非阻塞算法来实现并发控制
点进incrementAndGet()方法,发现调用了unsafe.getAndAddInt(this, valueOffset, 1) + 1;
在这里插入图片描述
在这里插入图片描述
再进入getAndAddInt中发现调用了compareAndSwapInt方法,这就是CAS方法,其中这个循环就是进行自旋操作,默认为自旋10次.
继续进入compareAndSwapInt方法,发现使用了native修饰符,那么就是一个本地方法,本地方法中使用了汇编命令进行CAS操作
在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值