JVM中的锁

  1. 什么是线程安全的?

Java 并发编程实战中 ,当多个线程同时访问同一个对象的时候,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象都会产生正确的结果,那就称这个对象是线程安全的。

就是一个对象在多线程的环境中运行,该对象总会产生正确的结果。

线程对立是产生死锁的条件,在Java 中 ,所以就尽量少使用线程对立的方法,比如Thread 类的suspend方法和resume 方法,线程中断和线程恢复,这两个方法已经被废弃

实现线程安全的几种方法

  1. 对象的绝对不可变,对象被创建之后,任何属性都不可以被修改,而且内部的任何引用不可泄漏或者任何引用都是不可变的。也就是说类 以及类内部的所有属性都是被final 关键字修饰,另外封装外面的数据任何情况都不可以触摸到里面的数据,而类内部的外部引用也必须是绝对不可变的,拿 String 类来举例子,String 类内部的所有属性在初始化之后就不可以被修改了,每天添加其实都是重新创建了的一个新的String 对象,在Java 中这样的不可变类,有String Number 的 一些子类 ,因为他们都采用了常量池技术,所有必须设置为不可变类,
  2. 互斥同步 ( 阻塞同步 ) 属于悲观锁

公共数据每次只能被一个线程访问

最基本步骤就是synchronized 关键字,这是一个重量级锁。在多线程编程的过程中尽量少用这种方式。然后就是重入锁ReentrantLock ,该锁用的更加灵活,等待可中断,( 当该线程等待获取锁的时候,可以设置线程等待时间,如果在规定时间内没有获得锁,则直接放弃这次操作。)公平锁,多个线程在获取同一个锁的时候,获取锁的先后顺序是根据等待时间长短依次获取锁。锁绑定多个条件

互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的造作都需要转入内核态中完成,这是很慢的。而且共享数据的锁定状态只会持续很短时间,

  1. 非阻塞同步 采用乐观锁的思想

互斥同步的思想有很大的优化空间,因为在大多数情况下,共享数据都不会出现竞争,但是都会对其进行锁,这就浪费了大量的时间和资源。这是悲观锁的设计思路。

我们可以采用乐观锁的设计思想来设计,也就是先不管风险,先进行操作,如果没有其他线程进行操作,那么操作成功了,如果共享资源被占用了,再进行补偿的措施,最常用的补偿就是不断地重试,直到没有竞争为止。但是这种操作需要硬件支持的原子性,也就是一条处理器指令就可以完成的

这类的指令有:

  • 测试并设置
  • 获取并增加
  • 交换
  • 比较并交换CAS
  • 加载链接/条件储存

这就和我们经常谈到的CAS 算法有关了,CAS 算法有三个量, V A B 这里,其中 V 是要修改数据在内存中的地址 ,A 是旧的数据,B 是新的数据,CAS 算法是通过CAS 指令,当且仅当A 符合 V 时,处理器才会更新B 值,注意CAS 指令是一个机器指令,也就是说是一个原子操作,这是CAS真正的解释,当然其他的机器指令也可能会有相应的同步算法,Java 中大多数的原子类和原子操作都用到了CAS 算法,我们以Integer 的原子类举个例子,来判断是否会发生同步问题

// 这里我测试了好多此,利用二十个线程去抢这个资源,是不会发送线程同步问题的
static AtomicInteger act = new AtomicInteger(0);
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 0 ; i < 100;i++){
                    act.incrementAndGet();
                    System.out.println(act);
                }
            }
        } ;
        ExecutorService executorService = Executors.newFixedThreadPool(200);
        for (int i = 0 ; i < 200 ; i ++){
            executorService.submit(runnable);
        }
        executorService.shutdown();
    }

// 输出
19994
19995
19996
19997
19998
19999
20000
// 我们换成`Integr` 试一下
static int act = 0;
    public static void main(String[] args) throws ExecutionException, InterruptedException {

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                for (int i = 0 ; i < 100;i++){
                    act++;
                    System.out.println(act);
                }
            }
        } ;
        ExecutorService executorService = Executors.newFixedThreadPool(200);
        for (int i = 0 ; i < 200 ; i ++){
            executorService.submit(runnable);
        }
        executorService.shutdown();
    }
19993
19994
19995
19996
19997
19998

我们可以看一下AtomicInteger 类中 increamentAndGet 方法的源码

public final int incrementAndGet() {
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
public final int getAndAddInt(Object var1, long var2, int var4) {
        int var5;
        do {
            var5 = this.getIntVolatile(var1, var2);
        } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

        return var5;
}

// 这里可以看到底层是一个循环,直到值相加成功之后才会退出
// 该方法中调用的方法是全部使用的 JVM 实现的 在类中明确标注,这些方法没有没有实现的代码,均为JVM 底层采用 CAS机器指令 支持实现的 

CAS算法的漏洞,这个和CAS 指令没有关系,

CAS 的实质是Compare And Swap 那么只要在比较的时候符合要求就可以了,所以这样就可以想到,当一个线程对共享数据加时,另一个线程对共享数据减的时候,在比较的时候,或者恰好是相当于没有进行变化的值,那么就直接骗过Compare 了 ,这就是CAS 算法的ABA 问题,对该类的处理方式,大部分的ABA 问题都不会程序并发的特性,如果需要解决该类问题,直接使用传统的同步锁就可以了

  1. 无同步方案

在高并发编程中,如果让一个方法不涉及共享数据,那它自然就不需要任何同步措施保证正确性。

可重入代码( 纯代码 ),特指该段代码在任何时候被中断,转而执行另一段代码( 包括递归调用本身 ),在控制权返回后,原来的程序不会出现任何错误,也不会对结果有影响。所有可重入代码都是线程安全的,反之不一定。

可重入代码的一些公共特性,不依赖全局变量,存储在堆上的数据和公用的系统资源都由参数传入,不调用不可重入的方法。另一种方式来说,就是说该方法的执行结果是可预测的,只要输入了相同的数据,就会返回相同的结果,平时刷的算法题都属于可重入代码。

线程本地存储,如果一段代码中所需要的数据必须与其他的代码共享,我们试一下,可以将这些共享数据的代码放在同一个线程中执行。如果可以,不需要线程同步也可以保证,线程之间不存在数据重用的概念。如果不可以,尽量使用少的线程,这样也可以节省计算机的系统资源。

这种例子有很多,一个简单的生产者消费者例子中,如果将生产者和消费者都放在同一个线程中,这个多线程问题就变成了单线程的问题,完全不需要考虑并发的问题。另如,经典Web 交换模型,一个请求对应着一个服务器线程,这些线程之间几乎不会考虑到线程安全的问题。

这里就需要提到volatile 关键字和ThreadLoacl类来实现线程本地存储。

volatile 关键字是将对象声明为易变的,这样对象在在变化时,其他线程才能感知到。而ThreadLoacl 类中,数据是以键值对的形式来存储数据,每一个线程对应着一个对象,这个存储技术与HashMap 技术是类似的

锁优化

  1. 自旋锁与自适应自选

前面我们提到了同步互斥的影响并发效率的主要原因是要将线程挂起和恢复,这些操作都是需要转入内核态中完成,运行时间比较长,但是其实共享数据被锁住的时间是很短的,为了这短暂的锁定时间,更付出将线程挂机和恢复的代价,是非常不值的。所有就有了自旋锁,当一个线程要访问共享资源的时候,发现共享资源被锁住,自旋锁不会将线程挂起,而是会在原地进行自选,直到共享资源被释放。这样就没有了线程的挂起和恢复的过程,这样锁的效率就会高上很多。但是同时也会出现问题,如果共享资源被占用时间比较短,这个工作效率就比较高,如果占用时间非常长呢!那么自旋线程的时间就会很长,这样就会浪费大量的系统资源,所以又产生了一个新的自旋锁优化,也就是自适应自旋锁,这种锁会预测公共资源占用的时间,若公共变量占用的时间非常长的话,就不会进行自选,还是采用老方法,线程挂起。

  1. 锁消除

锁消除的概念就是指虚拟机即时编译器在运行时,对一些代码要求同步,对被检测到不可能存在共享数据竞争的锁进行消除,

这个判断的依据是根据逃逸分析的数据支持。

  1. 锁粗化

我们在编写代码的过程,总是推荐将同步代码块的范围设置的尽量小,因为这样锁持有的时间是很短的,很快就会将锁释放,在一般情况下,这种设计是没有任何问题的,但是如果一个系统变量,一直被加锁,一直被解锁,这样的话,在整个过程中,大部分的时间都会被浪费在线程挂起和线程释放的这个过程,所以这时可以将锁的作用范围加大,覆盖整个加锁解锁的过程,这样就不会一直加锁解锁,只需要一次就可以了,这样提升了很多效率,但是这只是在很少情况下,才会使用的。

  1. 轻量级锁

java 虚拟机内部,每个对象都是有一个对象头的Mark work ,在不同的对象状态,对象头存储的内容是不同的,而对象的状态大概分为五种,

  • 未锁定状态
  • 轻量级锁定状态
  • 重量级锁定(锁膨胀)状态
  • GC 标记状态
  • 可偏向状态

在代码即将进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先在当前线程栈帧中建立一个名为锁记录的空间来存储锁对象当前的对象头的拷贝。然后虚拟机通过CAS 操作尝试把对象的对象头更新为指向栈帧中对象头的拷贝的指针,如果成功了,该线程就拥有了这个对象的锁,锁状态就变成了轻量级锁定状态,此时的对象头存储的是在栈桢中的地址。如果CAS 操作失败了,说明至少存在一条其他线程也在抢这个对象的锁,虚拟机会根据该对象的对象头来判断,若对象都指向的是当前线程的栈帧,那么当前线程成功抢到锁了,反之就没有,如果出现两个以上的线程抢这个锁,此时轻量级锁就没有用了,必须膨胀为重量级锁,其他线程就必须进入阻塞状态。这是加锁的过程,解锁的过程也是采用CAS 操作来执行的,将栈帧中的内容与对象头的内容呼唤,若互换失败,说明其他线程也在抢这个锁,那么在释放锁的过程中还会唤醒其他的挂起的线程。

在绝对多数情况下,同步的代码是不存在锁竞争的,也就是极少情况下,才会出现 多个线程同时抢同一个共享对象的情况,这是轻量级锁提升同步性能的原因,如果在多个线程同时抢一个共享对象时,那么轻量级锁与重量级锁相比,会更慢,因为过程会执行CAS 操作。

  1. 偏向锁

偏向锁的设计其实与轻量级锁类似,偏向锁是为消除数据在无竞争条件下的同步,进一步提升程序的运行性能。

根据字面意思,偏向锁就是偏向的锁,更偏向第一个获取锁的线程,当启用偏向时,当对象被第一个线程获取时,对象头会进入可偏向状态,对象头会记录线程的ID,这个操作也是采用CAS ,当该线程下一次再来访问,不需要抢锁,加锁解锁,相当于该对象是属于该线程的,此时如果出现另一个线程来访问该对象,那么偏向模式立马结束,然后进入轻量级锁的过程,

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值