zoukankan      html  css  js  c++  java
  • java并发编程(二)——加锁

    线程安全

    举个栗子:如果A窗口和B窗口在售卖同样的100张票,当这100张票卖完时,A窗口和B窗口关闭。看下代码实现

    public class Ticket implements Runnable{
    
        private int ticket = 5;
    
        public void run(){
            while (ticket > 0){
                   System.out.println(Thread.currentThread().getName() + "正在出售" + ticket + "号票");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                ticket--;
            }
        }
    
        public static void main(String[] args) {
            Ticket ticket = new Ticket();
            Thread a = new Thread(ticket,"A");
            Thread b = new Thread(ticket,"B");
    
            a.start();
            b.start();
        }
    }
    
    /*
    * 运行结果
    * A正在出售5号票
    * B正在出售5号票
    * B正在出售3号票
    * A正在出售4号票
    * A正在出售2号票
    * B正在出售1号票
    */

    由上面的程序和运行结果我们可以看到,线程A在打印自己出售的是哪张票后会睡眠1秒,而此时ticket并没有进行减减操作,这时候线程B也开始打印自己出售的是哪张票,因为ticket的值并没有减少,所以线程B打印的也是正在出售5号票。这样就会使两个窗口同时出售同一张票,在现实中是不允许的。这就是线程不安全的情况。为了不让这种情况发生,我们就需要保证线程安全。

    临界区

    一个程序运行多个线程本身是没有问题的,问题出在多个线程访问共享资源,多个线程读共享资源其实也没有问题,在多个线程对共享资源读写操作时发生指令交错,就会出现问题。一段代码块内如果存在对共享资源的多线程读写操作,称这段代码块为临界区。

    static int counter = 0;
    static void increment() 
    // 临界区
    { 
     counter++; }
    static void decrement() 
    // 临界区
    { 
     counter--; }

    要解决线程对临界资源读写时出现的问题,处理办法有多种。包括:

    1、阻塞式的解决方案:synchronized,Lock

    2、非阻塞式的解决方案:原子变量

    synchronized解决方案

    synchronized也叫对象锁,采用互斥的方式让同一时刻只有一个线程持有对象锁,其他线程想再获取这个锁,就会阻塞,这样就能保证拥有锁的线程可以安全的执行临界资源的代码,不用担心线程的上下文切换。

    其实就是A窗口和B窗口都在卖票,A窗口先来了顾客,这时候A就把票锁住了,虽然票还没有卖出去,但B窗口已经不能再去操作票了。等A窗口卖出一张票后,A和B窗口再同时开始卖票,谁先来了顾客,谁就会把票锁住。

    虽然 java 中互斥和同步都可以采用 synchronized 关键字来完成,但它们还是有区别的:互斥是保证同一时刻只能有一个线程执行临界区代码;同步是由于逻辑上线程的先后顺序,需要一个线程等待另一个线程执行到某个点。

    使用synchronized解决卖票问题

    /*synchronized语法
    * synchronized(对象){             //对象可以是任意,但要保证需要处理的多个线程使用的锁对象的是同一个对象
    *
    *       临界区
    *
    * }
    *
    * */
    
    public class Ticket implements Runnable{
    
        private int ticket = 5;
    
        Object object = new Object();  //将object作为锁对象
    
        public void run(){
            while (true){
                synchronized (object){    //将下面临界区代码锁住,保证A和B只有一个执行
                   if (ticket > 0){
                       System.out.println(Thread.currentThread().getName() + "正在出售" + ticket + "号票");
                       try {
                           Thread.sleep(100);
                       } catch (InterruptedException e) {
                           e.printStackTrace();
                       }
                       ticket--;
                   }else {
                       break;    //票卖完了就停止线程
                   }
                }
            }
        }
    
        public static void main(String[] args) {
            Ticket ticket = new Ticket();
            Thread a = new Thread(ticket,"A");
            Thread b = new Thread(ticket,"B");
    
            a.start();
            b.start();
        }
    }
    
    /*
    * 运行结果
    * A正在出售5号票
    * B正在出售4号票
    * B正在出售3号票
    * A正在出售2号票
    * A正在出售1号票
    */

     注意,如果一个线程持有对象锁时,它的CPU时间片用完了但是该线程还没有执行完,那么该线程仍然持有这把锁,其他的线程不能执行该临界区代码,当该线程再次分配到CPU时,它会继续执行,也就是说直到该线程执行完后它才会释放锁对象。synchronized实际是用对象锁保证了临界区代码的原子性,临界区内的代码对外是不可分割的,不会被线程切换所打断。

    synchronized的使用

    1、同步代码块,上面使用的方式就是同步代码块。

    2、同步方法

    class Test{
        public synchronized void test() {
    
        }
    }
    等价于
    class Test{
        public void test() {
            synchronized(this) {
    
            }
        }
    }

    3、静态同步方法 

    class Test{
        public synchronized static void test() {
        }
    }
    等价于
    class Test{
        public static void test() {
            synchronized(Test.class) {  //这里需要注意类对象和这个类的实例对象不是同一个对象
    
            }
        }
    }

    变量的线程安全分析

    成员变量和静态变量是否线程安全?

    1、如果它们没有共享,则线程安全

    2、如果它们被共享了,根据它们的状态是否能够改变,又分两种情况

    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

    局部变量是否线程安全?

    1、局部变量是线程安全的

    2、但局部变量引用的对象则未必

    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全

    局部变量线程安全分析

    public static void test1() {
        int i = 10;
        i++;
    }
    
    /*
    * 每一个线程都有自己私有的堆栈,本地方法区和程序计数器
    * 线程会将方法进行压栈操作,方法中的局部变量每个线程都会在栈桢内存中进行存储
    * 所以局部变量 i 不存在共享,没有线程安全问题
    * */

    成员变量线程安全分析

    import java.util.ArrayList;
    
    public class Demo{
    
        public static void main(String[] args) {
    
            ThreadUnsafe test = new ThreadUnsafe();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "A").start();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "B").start();
    
        }
    }
    
    class ThreadUnsafe {
    
        ArrayList<String> list = new ArrayList<>();    //成员变量 list,两个线程共享
    
        public void method1() {
            for (int i = 0; i < 200000; i++) {
                // { 临界区, 可能出现线程安全问题
    
                method2();
                method3();
    
                // } 临界区
            }
        }
        private void method2() {
            list.add("1");
        }
        private void method3() {
            list.remove(0);
        }
    }
    
    /*运行结果会报异常Exception in thread "ThreadB" java.lang.IndexOutOfBoundsException
    *原因是线程A和线程B共享了成员变量list,这样可能会出现
    * 线程A拿到空表list,在做add操作还没写回时,线程B也拿到了表list,
    * 因为线程A还没有写回,所以线程B拿到的也是一张空表,
    * 这个时候A写回后表里有一个数据,list[0]为 1,而此时B也开始写回,
    * B写回时也会把list[0]赋值为 1,也就是说B写回时覆盖了A的内容,
    * 此时两个线程写回后,表里只有一个数据,接着执行两次remove操作,就会出现异常
    * */

    将上面的例子做下修改,把成员变量list放到method1中,作为局部变量,再将list的引用作为参数传递给method2和method3,这样问题就解决了。

    import java.util.ArrayList;
    
    public class Demo{
    
        public static void main(String[] args) {
    
            ThreadUnsafe test = new ThreadUnsafe();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "A").start();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "B").start();
    
        }
    }
    
    class ThreadUnsafe {
    
        public void method1() {
            for (int i = 0; i < 200000; i++) {
    
                ArrayList<String> list = new ArrayList<>();  //局部变量 list,每个线程都会在自己的堆栈中进行存储
    
                // { 临界区, 可能出现线程安全问题
    
                method2(list);
                method3(list);
    
                // } 临界区
            }
        }
        private void method2( ArrayList<String> list) {
            list.add("1");
        }
        private void method3( ArrayList<String> list) {
            list.remove(0);
        }
    }
    
    /*list 是局部变量,每个线程调用时会创建其不同实例,没有共享
    * 而 method2 的参数是从 method1 中传递过来的,与 method1 中引用同一个对象
    * method3 的参数分析与 method2 相同
    * */

     暴露局部变量的引用

    在上面的例子中,如果把method2和method3的访问修饰符改为public,这样线程A和线程B之间还是不会出错,但如果在添加一个类,这各类继承ThreadUnsafe这个类并重写了method3,在method3中新建一个线程并执行,这样也会出现上面的异常。

    import java.util.ArrayList;
    
    public class Demo{
    
        public static void main(String[] args) {
    
            ThreadUnsafeSubClass test = new ThreadUnsafeSubClass();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "A").start();
    
            new Thread(() -> {
                test.method1();
            }, "Thread" + "B").start();
    
    
    
        }
    }
    
    class ThreadUnsafe {
    
        public void method1() {
            for (int i = 0; i < 20000; i++) {
    
                ArrayList<String> list = new ArrayList<>();  //局部变量 list,每个线程都会在自己的堆栈中进行存储
    
                // { 临界区, 可能出现线程安全问题
    
                method2(list);
                method3(list);
    
                // } 临界区
            }
        }
        public void method2( ArrayList<String> list) {
            list.add("1");
        }
        public void method3( ArrayList<String> list) {
            list.remove(0);
        }
    }
    
    class ThreadUnsafeSubClass extends ThreadUnsafe{
        @Override
        public void method2(ArrayList<String> list) {
            new Thread(() -> {
                list.add("1");
            }).start();
        }
    }
    /*list 虽然是局部变量,但是子类方法中的list对象和父类方法中是同一个,共享会出现问题
    * */

    Monitor机制——synchronized底层

    上面我们已经知道了synchronized可以用锁的办法保证线程的安全性(互斥),但是它在底层是如何实现的呢?这就需要了解Monitor机制了。在学习Monitor机制之前,我们先看下java对象在内存中是如何存储的。

    Java对象保存在内存中时,由三部分组成:对象头,实例数据,对齐填充字节(JVM要求对象占用的空间必须是8 的倍数)。

    java对象头

    一个对象在存储时,为了实现一些额外的功能,可能需要对这个对象做一些标记,这些标记标记字段就组成了对象头。

    java对象头由三部分组成:Mark Word,Klass Word(指向类的指针),Array Length(数组长度,只有数组对象才有)

    Mark Word:用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等等,占用内存大小与虚拟机位长一致。当一个对象被synchronized关键字当成同步锁时,围绕这个锁的一系列操作都和Mark Word有关。Mark Word在32位JVM中的长度是32bit,在64位JVM中长度是64bit。下图显示的是32位JVM中Mark Word的存储方式。

    |-------------------------------------------------------|--------------------|
    |                  Mark Word (32 bits)                  |       State        |
    |-------------------------------------------------------|--------------------|
    | identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 |       Normal       |
    |-------------------------------------------------------|--------------------|
    |  thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 |       Biased       |
    |-------------------------------------------------------|--------------------|
    |               ptr_to_lock_record:30          | lock:2 | Lightweight Locked |
    |-------------------------------------------------------|--------------------|
    |               ptr_to_heavyweight_monitor:30  | lock:2 | Heavyweight Locked |
    |-------------------------------------------------------|--------------------|
    |                                              | lock:2 |    Marked for GC   |
    |-------------------------------------------------------|--------------------|

    Mark Word在不同的锁状态下存储的内容不同,其中无锁和偏向锁的锁标志位都是01,只是在前面的1bit区分了这是无锁状态还是偏向锁状态(在下面synchronized优化中会讲到)。

    锁状态

    25bit

    4bit

    1bit

    2bit

    23bit

    2bit

    是否偏向锁

    锁标志位

    无锁

    对象的HashCode

    分代年龄

    0

    01

    偏向锁

    线程ID

    Epoch

    分代年龄

    1

    01

    轻量级锁

    指向栈中锁记录的指针

    00

    重量级锁

    指向重量级锁的指针

    10

    GC标记

    11

    JVM一般是这样使用锁和Mark Word的:

    1,当没有被当成锁时,这就是一个普通的对象,Mark Word记录对象的HashCode,锁标志位是01,是否偏向锁那一位是0。

    2,当对象被当做同步锁并有一个线程A抢到了锁时,锁标志位还是01,但是否偏向锁那一位改成1,前23bit记录抢到锁的线程id,表示进入偏向锁状态。

    3,当线程A再次试图来获得锁时,JVM发现同步锁对象的标志位是01,是否偏向锁是1,也就是偏向状态,Mark Word中记录的线程id就是线程A自己的id,表示线程A已经获得了这个偏向锁,可以执行同步锁的代码。

    4,当线程B试图获得这个锁时,JVM发现同步锁处于偏向状态,但是Mark Word中的线程id记录的不是B,那么线程B会先用CAS操作试图获得锁,这里的获得锁操作是有可能成功的,因为线程A一般不会自动释放偏向锁。如果抢锁成功,就把Mark Word里的线程id改为线程B的id,代表线程B获得了这个偏向锁,可以执行同步锁代码。如果抢锁失败,则继续执行步骤5。

    5,偏向锁状态抢锁失败,代表当前锁有一定的竞争,偏向锁将升级为轻量级锁。JVM会在当前线程的线程栈中开辟一块单独的空间,里面保存指向对象锁Mark Word的指针,同时在对象锁Mark Word中保存指向这片空间的指针。上述两个保存操作都是CAS操作,如果保存成功,代表线程抢到了同步锁,就把Mark Word中的锁标志位改成00,可以执行同步锁代码。如果保存失败,表示抢锁失败,竞争太激烈,继续执行步骤6。

    6,轻量级锁抢锁失败,JVM会使用自旋锁,自旋锁不是一个锁状态,只是代表不断的重试,尝试抢锁。从JDK1.7开始,自旋锁默认启用,自旋次数由JVM决定。如果抢锁成功则执行同步锁代码,如果失败则继续执行步骤7。

    7,自旋锁重试之后如果抢锁依然失败,同步锁会升级至重量级锁,锁标志位改为10。在这个状态下,未抢到锁的线程都会被阻塞。

    Klass Word:指向类的指针,该指针在32位JVM中的长度是32bit,在64位JVM中长度是64bit。JVM通过这个确定这个对象属于哪个类。

    Array Length:数组长度,只有数组对象保存了这部分数据。32位的JVM上,长度为32位;64位JVM则为64位。64位JVM如果开启 UseCompressedOops 选项,该区域长度也将由64位压缩至32位。

    走近Monitor

    什么是monitor?

    monitor直译过来是监视器的意思,专业一点叫管程。操作系统在解决线程同步问题时,会经过一系列的原语操作,程序员在使用这些原语时稍不小心就会引发问题,为了更好地实现并发编程,在操作系统支持的同步原语之上,又提出了更高层次的同步原语 monitor,它本质上是jvm用c语言定义的一个数据类型。值得注意的是,操作系统本身并不支持 monitor 机制,monitor是属于编程语言级别的,也就是当你想用monitor解决线程同步问题时,你得先看下你所使用的语言是否支持monitor原语。总的来说,monitor的出现是为了解决操作系统级别关于线程同步原语的使用复杂性,对复杂操作进行封装。而java则基于monitor机制实现了它自己的线程同步机制,就是synchronized内置锁。

    monitor的作用

    monitor的作用就是限制同一时刻,只有一个线程能进入monitor框定的临界区,达到线程互斥,保护临界区中临界资源的安全,这称为线程同步使得程序线程安全。同时作为同步工具,它也提供了管理 进程/线程 状态的机制,比如monitor能管理因为线程竞争未能第一时间进入临界区的其他线程,并提供适时唤醒的功能。

    monitor的组成

    1、monitor对象:使用monitor机制的目的主要是为了互斥进入临界区,为了做到能够阻塞无法进入临界区的 进程/线程,还需要一个monitor对象来协助。monitor对象是monitor机制的核心,这个monitor对象内部会有相应的数据结构,例如列表,来保存被阻塞的线程,它本质上是jvm用c语言定义的一个数据类型。同时由于 monitor 机制是基于 mutex 这种基本原语的,所以 monitor 对象还必须维护一个基于 mutex 的锁,monitor的线程互斥就是通过mutex互斥锁实现的。

    2、临界区:多个线程对共享资源读写操作的那段代码,其实就是synchronized包裹起来的那段代码。

    3、条件变量:条件变量的使用与下面 wait() 和 signal() 方法的使用密切相关,它是为了在适当的时候阻塞或者唤醒一个进程/线程。在线程获取锁进入临界区之后,如果发现条件变量不满足,monitor使用 wait() 使线程阻塞,条件变量满足后使用 signal() 唤醒被阻塞线程。

    4、定义在monitor对象上的wait() signal() signalAll()操作。

    举个栗子:监视器可以看做是经过特殊布置的建筑,这个建筑有一个特殊的房间,该房间通常包含一些数据和代码,但是一次只能一个消费者(线程)使用此房间,当一个消费者(线程)使用了这个房间,首先他必须到一个大厅(Entry Set)等待,调度程序将基于某些标准(如:先进先出)将从大厅中选择一个消费者(线程),进入特殊房间,如果这个线程因为某些原因被“挂起”,它将被调度程序安排到“等待房间”,并且一段时间之后会被重新分配到特殊房间,按照上面的线路,这个建筑物包含三个房间,分别是“特殊房间”、“大厅”以及“等待房间”。简单来说,监视器用来监视线程进入这个特别房间,他确保同一时间只能有一个线程可以访问特殊房间中的数据和代码。

    每个java对象都会与一个monitor相关联,当一个线程访问到一个对象的临界区代码时(使用synchronized修饰的),就会根据该对象的对象头中的Mark Word信息,找到与之关联的monitor对象,如果发现monitor对象中的特殊房间没有其他线程在使用,就会进入特殊房间,否则进入到大厅等候(BLOCKED状态)。如果一个线程进入了特殊房间,但因为它的条件变量不满足而不得不退出特殊房间,比如调用了wait(),此时这个线程就会进入等待房间被挂起(WAITING状态),直到它的条件变量满足后会再被分配到特殊房间。

    WaitSet:存放处于WAITING状态的线程队列。

    EntryList:存放处于等待获取锁BLOCKED状态的线程队列,即被阻塞的线程。

    Owner:指针,指向持有Monitor对象的线程。

    java中Monitor的实现

    先看下使用synchronized后的代码,通过编译生成的字节码文件。

    static final Object lock = new Object();
    static int counter = 0;
    public static void main(String[] args) {
        synchronized (lock) {
            counter++;
        }
    }

    对应的字节码为

    public static void main(java.lang.String[]);
        descriptor: ([Ljava/lang/String;)V
        flags: ACC_PUBLIC, ACC_STATIC
        Code:
            stack=2, locals=3, args_size=1
                0: getstatic #2      // <- lock引用 (synchronized开始)
                3: dup
                4: astore_1          // lock引用 -> slot 1
                5: monitorenter      // 将 lock对象 MarkWord 置为 Monitor 指针
                6: getstatic #3      // <- i
                9: iconst_1          // 准备常数 1
                10: iadd             // +1
                11: putstatic #3     // -> i
                14: aload_1          // <- lock引用
                15: monitorexit      // 将 lock对象 MarkWord 重置, 唤醒 EntryList
                16: goto 24
                19: astore_2         // e -> slot 2
                20: aload_1          // <- lock引用
                21: monitorexit      // 将 lock对象 MarkWord 重置, 唤醒 EntryList
                22: aload_2          // <- slot 2 (e)
                23: athrow           // throw e
                24: return
            Exception table:
                from to target type
                6 16 19 any
                19 22 19 any
            LineNumberTable:
                line 8: 0
                line 9: 6
                line 10: 14
                line 11: 24
            LocalVariableTable:
                Start Length Slot Name Signature
                0 25 0 args [Ljava/lang/String;
            StackMapTable: number_of_entries = 2
                frame_type = 255 /* full_frame */
                    offset_delta = 19
                    locals = [ class "[Ljava/lang/String;", class java/lang/Object ]
                    stack = [ class java/lang/Throwable ]
                frame_type = 250 /* chop */
                    offset_delta = 4

    上面的字节码显示,从序号 0 到 4,java会获取对象 lock 的引用并把它存到 slot_1中,这是为了在解锁时还原对象信息。序号 5 把 lock 对象的 Mark Word 修改为指向该对象锁关联的Monitor指针。接下来就会在Monitor对象中互斥的执行count++操作,也就是序号 6 到 11。当线程执行完后,也就是到了序号14,会把之前存储在slot_1中 lock 对象的引用取出来,将 lock 对象的Mark Word再次还原为之前的信息,之后唤醒EntryList中其他的线程。序号 16 之后的代码是为了保证当synchronized中的代码出现异常时,也能够释放锁。

    从上面可以看出,同步代码块是使用 monitorenter 和 monitorexit 指令包裹临界区实现同步。附:同步方法是使用ACC_SYNCHRONIZED方法访问标识符实现同步。

    Synchronized优化

    前面的学习我们知道了synchronized在底层是通过Monitor对象来实现线程同步的。但如果只是像上面一样,每个线程在执行临界区代码时都会通过Monitor对象,执行操作系统的同步原语,会浪费很多资源,因为操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,这也是为什么早期的synchronized效率低的原因,线程像这种依赖操作系统互斥量的加锁机叫重量级锁。为了提高程序的执行效率,java对synchronized做出了优化,引入了各种锁。

    锁状态

    25bit

    4bit

    1bit

    2bit

    23bit

    2bit

    是否偏向锁

    锁标志位

    无锁

    对象的HashCode

    分代年龄

    0

    01

    偏向锁

    线程ID

    Epoch

    分代年龄

    1

    01

    轻量级锁

    指向栈中锁记录的指针

    00

    重量级锁

    指向重量级锁的指针

    10

    GC标记

    11

    轻量级锁

    前面的学习中我们使用了很多的测试程序,在执行这些测试程序时,其实并不是每次都会出现线程安全的问题,很多时候运行结果是正常的。

    多个线程在执行临界区代码时,并不一定出现同时对共享资源的读写操作,很大可能是各个线程交替执行的,那就没必要每次都去执行操作系统的同步原语,基于这种情况,引入了轻量级锁的概念。引入轻量级锁的主要目的是,在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗(多指时间消耗)。

    轻量级锁的加锁过程

    1、当一个线程执行同步块(synchronized包裹的那部分临界区代码)的时候,如果同步对象(锁对象)的锁状态为无锁状态(无锁状态的锁标志位为 "01",轻量级锁的锁标志位为 "00"),该线程会在自己的栈桢中创建一个锁记录(Lock Record)对象。每一个线程的栈桢中都会包含一个锁记录的结构,内部Displaced Mard Word用于存储同步对象的Mark Word,Owner指针用来记录同步对像的地址。

    2、拷贝同步对像的Mark Word,并将其赋值给锁记录对象的Displaced Mard Word,将锁记录对象的Owner指针指向同步对像,而同步对象中的Mark Word则存储了锁记录对象的地址和锁状态 "00"。

    3、如果上面的更新操作成功了,那么这个线程就拥有了该同步对象的锁,并且同步对象Mark Word的锁标志位会设置为 “00”,即表示此对象处于轻量级锁定状态。

    4、如果上面的更新操作失败了,那么虚拟机首先会检查同步对象的Mark Word是否指向当前线程的栈帧,如果是就说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行(这种情况被称作锁重入,看下面下面代码)。否则说明多个线程竞争锁,轻量级锁就要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。

    锁重入:同一个线程在不同地方使用了同一个锁对象。

    static final Object obj = new Object();
    public static void method1() {
        synchronized( obj ) {
            // 同步块 A
            method2();
        }
    }
    public static void method2() {
        synchronized( obj ) {
            // 同步块 B
        }
    }

    假如线程在执行同步块A的时候获取了锁对象obj,当它执行method2()时,又会再一次尝试获取锁对象obj,由于此时锁对象obj的Mark Word中已经指向了当前线程的栈桢,所以虚拟机会判断当前线程持有该锁对象继续执行。然后该线程会在自己的栈桢中再创建了锁记录对象,该锁记录对象的Displaced Mard Word为null,当同步代码执行完后会进行解锁,如果锁记录对象的Displaced Mard Word为null时,就会删除该锁记录对象,如果不为null,就将锁记录对象中Displaced Mard Word赋值给锁对象的Mark Word,并且释放该锁对象,重置锁状态为 "01"。如果上面的解锁过程失败了,那么轻量级锁就会膨胀成重量级锁。

    锁膨胀:当一个线程A以轻量级锁的方式获得一个锁对象后,另一个线程B也想获取该锁对象,线程B开始会以轻量级锁的方式尝试获取,尝试获取失败后发现有其他线程持有该锁对象,就会用重量级锁的方式获取该锁对象,即轻量级锁膨胀为重量级锁。这时候线程B会进入Monitor对象的Entry List中,并且锁对象的Mark Word的值会复制给Monitor对象的Owner指针,即Monitor对象的Owner指针会指向当前持有锁对象的线程A中的锁记录对象,然后把锁对象的Mark Word修改为Monitor对象的地址,并把锁状态设置为 "10"。当线程A执行完需要重置锁对象的Mark Word时,发现锁状态为 "10"从而更新失败,此时线程A就会进入重量级锁流程,根据锁对象的Mark Word找到Monitor对象,设置Monitor对象中的Owner指针为null,然后唤醒Entry List中的其他线程。至此线程A会释放锁,并且Entry List中的线程B会获得锁并执行,直到执行结束释放锁。当线程B释放锁后,其他线程又可以用轻量级锁的方式获取该锁对象。

    锁自旋:由上面我们知道,一个线程会先以轻量级锁的方式去获取锁对象,如果该锁对象已经被别的线程加锁时,那么这个线程就会进入阻塞状态(BLOCKED)。但是,线程在阻塞与唤醒之间切换时会占用很多CPU的时间,而且有可能该线程刚进入阻塞状态就会被唤醒,为了减少CPU的时间消耗,引入了锁自旋。锁自旋就是当一个线程未获取到锁对象时,不会立刻进入阻塞状态,而是循环的去尝试获取锁,在一定次数后还未获取到锁,则会进入阻塞状态。JDK1.4.2中引入了锁自旋,并且在JKD1.6中得到了优化。在JDK1.6中,线程的循环次数默认为10次,当10次还未获取到锁时就会进入阻塞状态。线程在循环获取锁的时候同样会占用CPU时间,设定一个合适的循环次数是比较困难的,所以JDK1.6中还提供了自适应锁自旋,即循环的次数不是固定的,当这个锁对象上有线程自旋成功,即线程在一定循环次数内获取到了锁对象,那么虚拟机会认为这个锁对象的其他线程上次能成功,这次应该也能成功,就会增加自旋次数;反之,如果这个锁对象上其他线程自旋成功很少或者没有,就会减少自旋的次数甚至不自旋以避免CPU资源过多的浪费。

    轻量级锁的释放

    1、根据锁记录对象中的Owner指针找到锁对象,如果锁对象的锁状态位为 "00",则将锁对象中的Displaced Mard Word还原给锁对象,设置锁状态位为 "01",释放锁。

    2、如果上述步骤操作失败,即轻量级锁已经膨胀为重量级锁,那么根据锁对象的Mark Word,找到与之关联的Monitor对象

    3、将Monitor对象中的Owner指针设置为空,唤醒Entry List中阻塞的线程,释放锁并由新的线程竞争。

    偏向锁

    引入轻量级锁的目的:多个线程之间往往会交替的执行临界区代码,为了避免每次都使用重量级锁通过Monitor机制调用操作系统的同步原语而浪费CPU资源,引入了轻量级锁。

    偏向锁的目的:大多数情况下,锁不仅不存在多线程竞争,而且往往是同一个线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS的耗时操作)的代价而引入偏向锁。由上面Mark Word的信息,偏向锁的Mark Word后三位为 "101",而正常状态为 "001"。

    偏向锁的思想:线程会先以偏向锁的方式获取锁,然后锁对象的Mark Word中会存储当前线程的ID(操作系统分配的,用户不可操作),之后同一个线程再次来获取锁对象时不需要做任何操作,这样就减轻了每次重复的加锁和释放过程(cas操作)。偏向锁只会在第一次线程与Mark Word交换信息时使用CAS操作,当竞争出现时偏向锁就会撤销并且膨胀为轻量级锁。所以,如果要节省时间消耗,偏向锁撤销的耗时必然要小于节省下来的那些CAS操作。也就是说,偏向锁适用于线程锁竞争不激烈的环境,如果锁竞争激烈,频繁的做锁撤销操作,会浪费更多的时间。

    偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。

    偏向锁的加锁过程

    JVM默认偏向锁是开启的,也可以通过(-XX:+UseBiasedLocking)设置偏向锁开启,偏向锁默认是有延迟的,即不会再程序启动时立刻开启,可以通过(-XX:BiasedLockingStartupDelay=0)设置延迟为0。

    1、检测锁对象的Mark Word是否为可偏向状态,锁状态位(Mark Word最后两位)为 "01",是否偏向位biased标志位(Mark Word倒数第三位)为 "1"表示可偏向,biased标志位为 "0"表示不可偏向。即对象可设置偏向锁,则Mark Word最后三位为 "101"。如果不可偏向则以轻量级方式获取锁。

    2、如果锁对象可偏向,检测当前线程ID是否和锁对象中记录的线程ID一样,如果一样就执行第5步。

    3、如果当前线程ID和锁对象中记录的线程ID不一样,就通过CAS操作(一些原子操作)尝试获取锁对象,如果获取成功将(即没有其他线程持有锁对象)则将当前线程ID存入锁对象中的Mark Word中并执行第5步。

    4、如果获取失败(即已经有线程以偏向锁的方式持有了锁对象),则需要对偏向锁进行撤销,并将锁升级成轻量级锁,以轻量级锁的方式继续执行。撤销操作需要等到全局安全点(此时没有线程在执行字节码)。

    5、执行临界区代码。

    轻量级锁和偏向锁的使用场景为:轻量级锁是为了在线程交替执行同步块时提高性能,而偏向锁则是在只有一个线程执行同步块时进一步提高性能。

    偏向锁的撤销:偏向锁的撤销,需要等待全局安全点(在这个时间点上没有字节码正在执行),它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态,如果线程仍然活着,恢复到无锁(将锁对象的标志位设置为0,表示该对象不适合作偏向锁),最后唤醒之前暂停的线程。偏向锁只有遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁。

    偏向锁撤销后锁对象可能存在两种状态:

    • 不可偏向,未锁定状态。即锁对象的Mark Word中biased标志位为 "0",最后两位锁标志位为 "01"。出现这种情况是因为已偏向的锁对象在调用hashCode()时,会禁用掉偏向锁,如果这个锁对象又没有被其它线程持有,就会进入一种不可偏向的无锁状态。至于为什么会出现掉用hashCode()后禁用偏向锁,从上面对象头那节我们知道在32位系统中,对象的Mark Word中,HashCode占25个字节,而线程ID占23个字节,调用hashCode()时,一个已经偏向的对象的Mark Word中已经没有足够的字节存储HashCode,所以需要禁用偏向锁。
    • 轻量级锁状态。即锁对象的Mark Word中biased标志位为 "0",最后两位锁标志位为 "00"。这种情况是其它线程竞争偏向锁时,会使偏向锁膨胀为轻量级锁。

    偏向锁的释放

    偏向锁在直观意义上没有释放锁的过程,因为只要没有其他线程来竞争锁,这个锁对象中会一直存着上一个以偏向锁的方式获取锁对象的线程ID。不会尝试将 Mark Word 中的 Thread ID 赋回原值。这样做的好处是: 如果该线程需要再次对这个对象加锁,而这个对象之前一直没有被其他线程尝试获取过锁,依旧停留在可偏向的状态下, 即可在不修改对象头的情况下, 直接认为偏向成功。

    批量重偏向

    没搞懂!!!

    BiasedLocking模式下markOop中位域epoch的根本作用是什么?

    java 偏向锁

    偏向锁

    偏向锁详解

    不同锁对象之间的状态转换

    上面理解了的话,对这个图也很好理解。

    wait和notify

    wait()和sleep()的区别

    1、sleep()是Thred类中的一个静态方法,wait是Object类中的成员方法。

    2、wait()只能用在synchronized块中,而sleep()可以用在任何地方。

    3、使用sleep()时可能出现异常,需手动处理(捕获或者抛出),而使用wait不用手动处理。

    4、使用wait()当前线程会放弃锁,如果指定时间,该线程会进入TIMED_WAITING状态,如果不指定时间会进入WAITING状态。而使用sleep()当前线程不会放弃锁,进入TIMED_WAITING态。

    notify

    当多个线程,对同一个对象object调用了object.wait()后,如果使用object.notify()会随机的唤醒一个线程,而使用object.notifyAll()会唤醒所有等待的线程。那么如何只唤醒我们需要的线程呢?我们只需要将判断时的if换成while就行了。

    public class Demo {
    
        static final Object room = new Object();
        static boolean hasCigarette = false;
        static boolean hasTakeout = false;
    
        public static void main(String[] args) throws InterruptedException {
    
            new Thread(() -> {
                synchronized (room) {
                    System.out.println("有烟没?"+hasCigarette);
                    //如果将此处改成while,那么即使唤醒了该线程,只要hasCigarette为false,就会继续等待
                    if (!hasCigarette) {
                        System.out.println("没烟,先歇会!");
                        try {
                            room.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("有烟没?"+hasCigarette);
                    if (hasCigarette) {
                        System.out.println("可以开始干活了");
                    } else {
                        System.out.println("没干成活...");
                    }
                }
            }, "小南").start();
            new Thread(() -> {
                synchronized (room) {
                    Thread thread = Thread.currentThread();
                    System.out.println("外卖送到没?"+hasTakeout);
                    if (!hasTakeout) {
                        System.out.println("没外卖,先歇会!");
                        try {
                            room.wait();
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    System.out.println("外卖送到没?"+hasTakeout);
                    if (hasTakeout) {
                        System.out.println("可以开始干活了");
                    } else {
                        System.out.println("没干成活...");
                    }
                }
            }, "小女").start();
            Thread.sleep(1);
            new Thread(() -> {
                synchronized (room) {
                    hasTakeout = true;
                    System.out.println("外卖到了噢!");
                    room.notify();
                }
            }, "送外卖的").start();
        }
    }
    /*
        有烟没?false
        没烟,先歇会!
        外卖送到没?false
        没外卖,先歇会!
        外卖到了噢!
        有烟没?false
        没干成活...
     */

     设计模式——保护性暂停

    如果线程1需要等待线程2执行完之后才能执行,我们可以在线程1中使用wait()来解决。如果线程1中还需用用到线程2的结果,那么我们只能通过全局变量的形式将线程2的运行结果传递给线程1。这与面向对象的理念不符,为了解决这个问题,一个线程需要用到另一个线程的结果,我们可以使用保护性暂停设计模式。

    保护性暂停模式:使用一个保护性对象GuardedObject,里面有两方法get()和set(),以及一个用于传递线程2的返回值的私有属性response。

    package com.bingfa;
    
    public class GuardedTest {
    
        public static void main(String[] args) {
            GuardedObject guardedObject = new GuardedObject();
    
            new Thread(()->{
                int sum = 0;
                int a = 10;
                int b;
                b = Integer.parseInt(guardedObject.get().toString()); //此时线程1会进入WAITING状态
                sum = a + b;
                System.out.println("sum = " + sum);
            }, "线程2").start();
    
            new Thread(()->{
                int response = 0;
                response = 1 + 1;
                guardedObject.set(response);  //线程2执行完后将response通过guardedObject对象返回给线程1,并唤醒了线程1
            }, "线程2").start();
        }
    
    }
    
    class GuardedObject{
    
        private Object response;   //用于结果的传递
    
        public synchronized Object get() {
            while(response == null){    //此处使用while,上面已经介绍过
                try {
                    System.out.println("等待线程2的结果");
                    this.wait();   //线程2没有返回结果,继续等待
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
    
            return response;  //线程2已经返回response,则线程1可以使用了
        }
    
        public synchronized void set(Object response){
            System.out.println("线程2结果返回");
            this.response = response;
            this.notifyAll();   //已经产生结果,唤醒等待的线程
        }
    }

     park & unpark

    park 和 unpark是LockSupport类中的两个静态方法,LockSupport.park()和LockSupport.unpark()。作用与wait和notify类似,不过wait和notify需要在monitor通过来实现,而park和unpark是以线程为单位,并且unpark可以明确唤醒哪个线程。LockSuppor.unpark(t1),唤醒t1线程。unpark表示一种许可,一个线程可以先获取许可,之后使用park时如果有这种许可就不会进入等待状态,即unpark可以先于park使用。

    参考资料

    java对象头详解

    java对象头和对象组成详解

    面试官和我扯了半个小时的synchronized,最后他输了

    Java 中的 Monitor 机制

    java并发系列-monitor机制实现

    监视器–JAVA同步基本概念

    Java Synchronized实现原理

    Java并发编程:Synchronized底层优化(偏向锁、轻量级锁)

    java 偏向锁

  • 相关阅读:
    利用兼容DC和兼容位图实现图形重绘
    MFC实现文件打开和保存功能实现
    CFile文件操作示例
    利用互斥对象实现线程同步的实例说明
    bootstrap3 input 验证样式【转】
    js event 冒泡和捕获事件详细介绍【转】
    Html+Ajax+Springmvc+Mybatis,不用JSP【转】
    hp电脑重装win7 64位 后 所有软件都装不上问题【转】
    bootstrap 模态 modal 小例子【转】
    servlet 获取 post body 体用流读取为空的问题【转】
  • 原文地址:https://www.cnblogs.com/Zz-feng/p/13153723.html
Copyright © 2011-2022 走看看