Java中的锁以及sychronized实现机制(十)

上节讲了线程安全和原子性,其实就是并发代码变成同步,意味这代码只有一个人在使用,这样就不会有问题。

(一)Java中的锁

1.自旋锁

为了不放弃CPU执行时间,循环的使用CAS技术对数据尝试进行更新,直至成功。(乐观锁的实现)

2.悲观锁

假定会发生并发冲突,同步所有对数据的相关操作,从读数据就开始上锁。(从读数据就开始上锁。)

3.乐观锁

假定没有冲突,在修改数据时如果发生数据和之前获取的不一致,则读最新数据,修改后重试修改。(假定能成功,如果不成功也是CAS的操作)

4.独享锁(写)

给资源加上写锁,线程可以修改资源,其他线程不能再加锁(单写)(互斥锁)(就 像谈恋爱,被女朋友锁定了,其他人无法获取你)

5.共享锁(读)

给资源加上读锁只能读不能改,其他线程只能加读锁,不能加写锁(多读)(就像信息已经放给婚介了,谁都可以获取我的信息)

6.可重入锁,不可重入锁

线程拿到一把锁之后,可以自由进入同一把锁所同步的其他代码

public class ObjectSyncDemo2 {

    public synchronized void test1(Object arg) {
        System.out.println(Thread.currentThread() + " 我开始执行 " + arg);
        if (arg == null) {
            test1(new Object());
        }
        System.out.println(Thread.currentThread() + " 我执行结束" + arg);
    }

    public static void main(String[] args) throws InterruptedException {
        new ObjectSyncDemo2().test1(null);
    }
}


7.公平锁,非公平锁

争抢锁的顺序,如果按先来后到,则为公平。
公平锁:线程1拿到锁,线程2在等待,线程3也在等待,线程1执行完释放锁,线程2就拿到这个锁。
非公平锁:线程1拿到锁,线程2在等待,线程3也在等待,线程1执行完释放锁,线程3就拿到这个锁,本来应该线程2拿到锁的,线程3先拿到了。就是不公平。

(二)同步关键字synchronized

  • ① 介绍

最基本的线程通信机制,基于对象监视器实现的。
JAVA中的每个对象都与一个监视器相关联,个线程可以锁定或解锁。
一次只有一个线程可以锁定监视器。
试图锁定该监视器的任何其他线程都会被阻塞,知道它们可以获得该监视器上的锁定为止。

  • ② 特性

可重入,独享,悲观锁。

  • ③ 锁的范围

类锁,对象锁,锁消除,锁粗化

同步关键字,不仅是实现同步,根据JMM规定还能保证可见性(读取最新主内存数据,结束后写入主内存)

  • ④ 锁消除

发生在编译器级别的一种锁优化方式。完全不需要加锁,却执行了加锁操作。

// 锁消除(jit)
public class ObjectSyncDemo {
    public void test3(Object arg) {
        StringBuilder builder = new StringBuilder();
        builder.append("a");
        builder.append(arg);
        builder.append("c");
        System.out.println(arg.toString());
    }

    public void test2(Object arg) {
        String a = "a";
        String c = "c";
        System.out.println(a + arg + c);
    }


    public void test1(Object arg) {
        // jit 优化, 消除了锁
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append("a");
        stringBuffer.append(arg);
        stringBuffer.append("c");
        // System.out.println(stringBuffer.toString());
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000000; i++) {
            new ObjectSyncDemo().test1("123");
        }
    }
}

test1()方法中的StringBuffer数以函数内部的局部变量,进作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作

StringBuffer 的源码

  @Override
    public synchronized StringBuffer append(String str) {
        toStringCache = null;
        super.append(str);
        return this;
    }

去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

  • ④ 锁粗化

通常情况下,为了保证多线程间的有效并发,会要求每个线程持有锁的时间尽可能短,但是某些情况下,一个程序对同一个锁不间断、高频地请求、同步与释放,会消耗掉一定的系统资源,因为锁的讲求、同步与释放本身会带来性能损耗,这样高频的锁请求就反而不利于系统性能的优化了,虽然单次同步操作的时间可能很短。锁粗化就是告诉我们任何事情都有个度,有些情况下我们反而希望把很多次锁的请求合并成一个请求,以降低短时间内大量锁请求、同步、释放带来的性能损耗。

// 锁粗化(运行时 jit 编译优化)
// jit 编译后的汇编内容, jitwatch可视化工具进行查看
public class ObjectSyncDemo {
    int i;

    public void test1(Object arg) {
        synchronized (this) {
            i++;
        }
        synchronized (this) {
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000000; i++) {
            new ObjectSyncDemo().test1("a");
        }
    }
}

在test1() 同步操作的代码之间,需要做一些其它的工作,而这些工作只会花费很少的时间,那么我们就可以把这些工作代码放入锁内,将两个同步代码块合并成一个,以降低多次锁请求、同步、释放带来的系统性能消耗,合并后的代码如下

前提中间不需要同步的代码能够很快速地完成,如果不需要同步的代码需要花很长时间,就会导致同步块的执行需要花费很长的时间,这样做也不合理。

 public void test1(Object arg) {
        synchronized (this) {
            i++;
            i++;
        }
    }

(三)同步关键字加锁原理

  • ① JAVA对象头

锁的实现机制与java对象头息息相关,锁的所有信息,都记录在java的对象头中。用2字(32位JVM中1字=32bit=4baye)存储对象头,如果是数组类型使用3字存储(还需存储数组长度)。对象头中记录了hash值、GC年龄、锁的状态、线程拥有者、类元数据的指针。

  • ② 几种锁类型

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

  • ③ 偏向锁(A线程独占锁,不用上下文切换。对象头标识)

大多数情况下锁不仅不存在多线程竞争,而且总是由同一线程多次获得。偏向锁的目的是在某个线程获得锁之后,消除这个线程锁重入(CAS)的开销,看起来让这个线程得到了偏护。另外,JVM对那种会有多线程加锁,但不存在锁竞争的情况也做了优化,听起来比较拗口,但在现实应用中确实是可能出现这种情况,因为线程之前除了互斥之外也可能发生同步关系,被同步的两个线程(一前一后)对共享对象锁的竞争很可能是没有冲突的。对这种情况,JVM用一个epoch表示一个偏向锁的时间戳(真实地生成一个时间戳代价还是蛮大的,因此这里应当理解为一种类似时间戳的identifier)

偏向锁的获取

当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要花费CAS操作来加锁和解锁,而只需简单的测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁,如果测试成功,表示线程已经获得了锁,如果测试失败,则需要再测试下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁),如果没有设置,则使用CAS竞争锁,如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。

偏向锁的撤销

偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word,要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。

偏向锁的设置

关闭偏向锁:偏向锁在Java 6和Java 7里是默认启用的,但是它在应用程序启动几秒钟之后才激活,如有必要可以使用JVM参数来关闭延迟-XX:BiasedLockingStartupDelay = 0。如果你确定自己应用程序里所有的锁通常情况下处于竞争状态,可以通过JVM参数关闭偏向锁-XX:-UseBiasedLocking=false,那么默认会进入轻量级锁状态。

  • ④自旋锁(一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。一定要有次数限制)

线程的阻塞和唤醒需要CPU从用户态转为核心态,频繁的阻塞和唤醒对CPU来说是一件负担很重的工作。同时我们可以发现,很多对象锁的锁定状态只会持续很短的一段时间,例如整数的自加操作,在很短的时间内阻塞并唤醒线程显然不值得,为此引入了自旋锁。

所谓“自旋”,就是让线程去执行一个无意义的循环,循环结束后再去重新竞争锁,如果竞争不到继续循环,循环过程中线程会一直处于running状态,但是基于JVM的线程调度,会出让时间片,所以其他线程依旧有申请锁和释放锁的机会。

自旋锁省去了阻塞锁的时间空间(队列的维护等)开销,但是长时间自旋就变成了“忙式等待”,忙式等待显然还不如阻塞锁。所以自旋的次数一般控制在一个范围内,例如10,100等,在超出这个范围后,自旋锁会升级为阻塞锁。

  • ④ 轻量级锁(A线程拥有锁,B获取,竞争,自旋)
    加锁

线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,则自旋获取锁,当自旋获取锁仍然失败时,表示存在其他线程竞争锁(两条或两条以上的线程竞争同一个锁),则轻量级锁会膨胀成重量级锁。

解锁

轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程。

  • ⑤ 重量级锁(B线程自旋获取不到锁,膨胀重量锁,阻塞A线程。直到B执行完。)

重量锁在JVM中又叫对象监视器(Monitor),它很像C中的Mutex,除了具备Mutex(0|1)互斥的功能,它还负责实现了Semaphore(信号量)的功能,也就是说它至少包含一个竞争锁的队列,和一个信号阻塞队列(wait队列),前者负责做互斥,后一个用于做线程同步。

PS:有数据表明,除去大型互联网公司,80%的系统不存在多线程的竞争的情况,一定要熟悉这几种锁,对以后面试镀金(面试)真的很有用。

你可能感兴趣的:(Java中的锁以及sychronized实现机制(十))