Java并发编程——进程和线程Java对象内存布局synchronizedwait和notify

Posted AC_Jobim

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程——进程和线程Java对象内存布局synchronizedwait和notify相关的知识,希望对你有一定的参考价值。

一、进程和线程

进程和线程的区别?

进程:进程是程序的一次执行过程。是CPU资源分配的最小单位。每个进程都有自己独立的一块内存空间,一个进程可以有多个线程

线程:线程是CPU调度的最小单位,它可以和属于同一个进程的其他线程共享这个进程的全部资源

进程和线程的区别

  • 根本区别:进程是操作系统资源分配的基本单位,而线程是处理器任务调度和执行的基本单位

  • 资源开销:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器(PC),线程之间切换的开销小。

  • 包含关系:一般一个进程内有多个线程,执行过程不是一条线的,而是多条线(线程)共同完成的;线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。

  • 内存分配:同一进程的线程共享本进程的地址空间和资源,而进程之间的地址空间和资源是相互独立的

  • 影响关系:一个进程崩溃后,在保护模式下不会对其他进程产生影响。但是一个线程崩溃可能导致整个进程都死掉。所以多进程要比多线程健壮。

从 JVM 角度说进程和线程之间的关系: ( 待补)

并行和并发有什么区别?

  • 并行是指两个或者多个事件在同一时刻发生
  • 并发是指两个或多个事件在同一时间间隔发生

二、Java线程

2.1 创建线程的四种方式

  1. 创建继承于Thread类的子类,并重写Thread类的run()方法

  2. 创建一个实现了Runnable接口的类,并实现run()方法

  3. 通过Callable和FutureTask创建线程

    1. 创建一个实现Callable的实现类,并实现call方法
    2. 将Callable接口实现类的对象作为传递到FutureTask构造器中,创建FutureTask的对象
    3. 将FutureTask的对象作为参数传递到Thread类的构造器中,创建Thread对象,并调用start()
    4. 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值
    @Test
    public void test03() throws ExecutionException, InterruptedException {
        // 实现多线程的第三种方法可以返回数据
        FutureTask futureTask = new FutureTask<>(new Callable<Integer>() {
            @Override
            public Integer call() throws Exception {
                log.debug("多线程任务");
                Thread.sleep(100);
                return 100;
            }
        });
        // 主线程阻塞,同步等待 task 执行完毕的结果
        new Thread(futureTask,"分线程").start();
        log.debug("主线程");
        log.debug("{}",futureTask.get()); //获得分线程的返回值,get方法为阻塞方法
        
    }
    
  4. 使用线程池

    class NumberThread implements Runnable{
        @Override
        public void run() {
            for(int i = 0;i<10;i++){
                System.out.println(Thread.currentThread().getName()+":"+i);
            }
        }
    }
    
    class Number2Thread implements Callable {
        @Override
        public Object call() throws Exception {
            int sum = 0;
            for(int i = 1;i<=10;i++){
                System.out.println(Thread.currentThread().getName()+":"+i);
                sum+=i;
            }
            return sum;
        }
    }
    
    public class ThreadPool {
        public static void main(String[] args) {
            //1. 提供指定线程数量的线程池
            ExecutorService service = Executors.newFixedThreadPool(10);//创建一个可重用固定线程数为10的线程池
    
            //查看该对象是哪个类造的
            System.out.println(service.getClass());//class java.util.concurrent.ThreadPoolExecutor
            //设置线程池的属性
    //        service1.setCorePoolSize(15);
    //        service1.setKeepAliveTime();
    
            //2.执行指定的线程的操作。需要提供实现Runnable接口或Callable接口实现类的对象
            service.execute(new NumberThread());//适合使用于Runnable
            Future future = service.submit(new Number2Thread());//适合使用于Callable
            try {
                System.out.println(future.get());//输出返回值
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            }
            //3.关闭连接池
            service.shutdown();
        }
    }
    

runnable 和 callable 有什么区别?

  • 相同点

    • 都是接口
    • 都可以编写多线程程序
    • 都采用Thread.start()启动线程
  • 主要区别

    • Runnable 接口 run 方法无返回值;Callable 接口 call 方法有返回值,支持泛型,和Future、FutureTask配合可以用来获取异步执行的结果
    • Runnable 接口 run 方法只能抛出运行时异常,且无法捕获处理;Callable 接口 call 方法允许抛出异常,可以获取异常信息
      注:Callalbe接口支持返回执行结果,需要调用FutureTask.get()得到,此方法会阻塞主进程的继续往下执行,如果不调用不会阻塞。

线程的 run()和 start()有什么区别?

  • start() 方法用于启动线程,run() 方法用于执行线程的运行时代码。run() 可以重复调用,而 start() 只能调用一次。 多次调用会抛出 java.lang.IllegalThreadStateException 异常

  • new 一个 Thread,线程进入了新建状态。调用 start() 方法,会启动一个线程并使线程进入了就绪状态,当分配到时间片后就可以开始运行了。 start() 会执行线程的相应准备工作,然后自动执行 run() 方法的内容,这是真正的多线程工作。

  • 而直接执行 run() 方法,会把 run 方法当成一个 main 线程下的普通方法去执行,并不会在某个线程中执行它,所以这并不是多线程工作。

2.2 线程的生命周期

  1. 新建(new):新创建了一个线程对象。

  2. 就绪(runnable):线程对象创建后,当调用线程对象的 start()方法,该线程处于就绪状态,等待被线程调度选中,获取cpu的使用权。

  3. 运行(running):可运行状态(runnable)的线程获得了cpu时间片(timeslice),执行程序代码。注:就绪状态是进入到运行状态的唯一入口,也就是说,线程要想进入运行状态执行,首先必须处于就绪状态中;

  4. 阻塞(block):处于运行状态中的线程由于某种原因,暂时放弃对 CPU的使用权,停止执行,此时进入阻塞状态,直到其进入到就绪状态,才有机会再次被 CPU 调用以进入到运行状态。

    阻塞的情况分三种:

    1. 等待阻塞:运行状态中的线程执行 wait()方法,JVM会把该线程放入等待队列(waitting queue)中,使本线程进入到等待阻塞状态;
    2. 同步阻塞:线程在获取 synchronized 同步锁失败(因为锁被其它线程所占用),,则JVM会把该线程放入锁池(lock pool)中,线程会进入同步阻塞状态;
    3. 其他阻塞: 通过调用线程的 sleep()或 join()或发出了 I/O 请求时,线程会进入到阻塞状态。当 sleep()状态超时、join()等待线程终止或者超时、或者 I/O 处理完毕时,线程重新转入就绪状态。
  5. 死亡(dead):线程run()、main()方法执行结束,或者因异常退出了run()方法,则该线程结束生命周期。死亡的线程不可再次复生。

线程的六种状态:

  • 这是从 Java API 层面来描述的。根据Thread.State 枚举,分为六种状态

  • NEW (新建状态) 线程刚被创建,但是还没有调用 start() 方法

  • RUNNABLE (运行状态) 当调用了 start() 方法之后,注意,Java API 层面的RUNNABLE 状态涵盖了操作系统层面的 【就绪状态】、【运行中状态】和【阻塞状态】(由于 BIO 导致的线程阻塞,在 Java 里无法区分,仍然认为 是可运行)

  • BLOCKED (阻塞状态)WAITING (等待状态)TIMED_WAITING(定时等待状态) 都是 Java API 层面对【阻塞状态】的细分,如sleep就位TIMED_WAITINGjoinWAITING状态。

  • TERMINATED (结束状态) 当线程代码运行结束

2.3 线程的状态转换(API层次)

假设有线程 Thread t

  • 情况1:NEW –> RUNNABLE

    • 当调用t.start()方法时, NEW --> RUNNABLE
  • 情况2:RUNNABLE <–> WAITING

    • t线程用synchronized(obj)获取了对象锁后
      • 调用 obj.wait()方法时,t 线程进入waitSet中, 从RUNNABLE --> WAITING
      • 调用obj.notify()obj.notifyAll()t.interrupt()时, 唤醒的线程都到entrySet阻塞队列成为BLOCKED状态, 在阻塞队列,和其他线程再进行竞争锁
        • 竞争锁成功,t 线程从 WAITING --> RUNNABLE
        • 竞争锁失败,t 线程从 WAITING --> BLOCKED
  • 情况3:RUNNABLE <–> WAITING

    • 当前线程调用 t.join() 方法时,当前线程RUNNABLE --> WAITING
      • 注意是当前线程在t线程对象在waitSet上等待
    • t 线程运行结束,或调用了当前线程的 interrupt() 时当前线程WAITING --> RUNNABLE
  • 情况4:RUNNABLE <–> WAITING

    • 当前线程调用 LockSupport.park() 方法会让当前线程RUNNABLE --> WAITING
    • 调用 LockSupport.unpark(目标线程) 或调用了线程 的 interrupt() ,会让目标线程从 WAITING --> RUNNABLE
  • 情况5:RUNNABLE <–> TIMED_WAITING(带超时时间的wait)

    • t 线程用synchronized(obj)获取了对象锁后
      • 调用 obj.wait(long n) 方法时,t 线程从 RUNNABLE --> TIMED_WAITING
      • t 线程等待时间超过了 n 毫秒,或调用 obj.notify() , obj.notifyAll() , t.interrupt() 时; 唤醒的线程都到entrySet阻塞队列成为BLOCKED状态, 在阻塞队列,和其他线程再进行竞争锁
        • 竞争锁成功,t 线程从 TIMED_WAITING --> RUNNABLE
        • 竞争锁失败,t 线程从 TIMED_WAITING --> BLOCKED
  • 情况6:RUNNABLE <–> TIMED_WAITING

    • 当前线程调用 t.join(long n) 方法时,当前线程从 RUNNABLE --> TIMED_WAITING 注意是当前线程在t 线程对象的waitSet等待
    • 当前线程等待时间超过了 n 毫秒,或t 线程运行结束,或调用了当前线程的 interrupt() 时,当前线程从 TIMED_WAITING --> RUNNABLE
  • 情况7:RUNNABLE <–> TIMED_WAITING

    • 当前线程调用 Thread.sleep(long n) ,当前线程从 RUNNABLE --> TIMED_WAITING
    • 当前线程等待时间超过了 n 毫秒或调用了线程的 interrupt() ,当前线程从 TIMED_WAITING --> RUNNABLE
  • 情况8:RUNNABLE <–> TIMED_WAITING

    • 当前线程调用 LockSupport.parkNanos(long nanos) 或 LockSupport.parkUntil(long millis) 时,当前线程从 RUNNABLE --> TIMED_WAITING
    • 调用LockSupport.unpark(目标线程) 或调用了线程的interrupt() ,或是等待超时,会让目标线程从 TIMED_WAITING--> RUNNABLE
  • 情况9:RUNNABLE <–> BLOCKED

    • t 线程用 synchronized(obj) 获取了对象锁时如果竞争失败,从 RUNNABLE –> BLOCKED
    • 持 obj 锁线程的同步代码块执行完毕,会唤醒该对象上所有 BLOCKED 的线程重新竞争,如果其中 t 线程竞争 成功,从 BLOCKED –> RUNNABLE ,其它失败的线程仍然 BLOCKED
  • 情况10:RUNNABLE –> TERMINATED

    • 当前线程所有代码运行完毕,进入 TERMINATED

2.4 线程运行原理

虚拟机栈与栈帧

  • 虚拟机栈描述的是Java方法执行的内存模型每个方法被执行的时候都会同时创建一个栈帧(stack frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,是属于线程的私有的。当Java中使用多线程时,每个线程都会维护它自己的栈帧!每个线程只能有一个活动栈帧(在栈顶),对应着当前正在执行的那个方法

线程上下文切换(Thread Context Switch)

因为以下一些原因导致 cpu 不再执行当前的线程,转而执行另一个线程的代码

  • 线程的 cpu 时间片用完(每个线程轮流执行,看前面并行的概念)
  • 垃圾回收
  • 有更高优先级的线程需要运行
  • 线程自己调用了 sleepyieldwaitjoinparksynchronizedlock 等方法

Thread Context Switch发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条 jvm 指令的执行地址,是线程私有的

  • 线程的状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等
  • Context Switch 频繁发生会影响性能

2.5 守护线程

  • 守护线程,是指在程序运行的时候在后台提供一种通用服务的线程
  • Java进程中有多个线程在执行时,只有当所有非守护线程都执行完毕后,Java进程才会结束。但当非守护线程全部执行完毕后,守护线程无论是否执行完毕,也会一同结束。普通线程t1可以调用t1.setDeamon(true); 方法变成守护线程

注意:

  • 垃圾回收器线程就是一种守护线程
  • Tomcat 中的 Acceptor 和 Poller 线程都是守护线程,所以 Tomcat 接收到 shutdown 命令后,不会等

三、Java对象内存布局和对象头

在 JVM 中,Java对象保存在堆中时,由以下三部分组成

  • 对象头(object header):包括了关于堆对象的布局、类型、GC状态、同步状态和标识哈希码的基本信息。Java对象和vm内部对象都有一个共同的对象头格式。
  • 实例数据(Instance Data):主要是存放类的数据信息,父类的信息,对象字段属性信息。
  • 对齐填充(Padding):为了字节对齐,填充的数据,不是必须的。默认情况下,Java虚拟机堆中对象的起始地址需要对齐至8的倍数。如果一个对象用不到8N个字节则需要对其填充

即:对象示例 = 对象头 + 实例数据 + 对齐填充


对象头分为两类信息:一类是Mark Word用(于存储对象自身的运行时数据),一类是Klass Point(类型指针)。

  • 第一部分是Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特。

  • 第二部分是Klass Point(类型指针),即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例

  • 此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据。

即:对象头 = 对象标记markword + 类型指针

即:对象在堆内存中的整体结构布局

Mark Word:

Mark Word在不同的锁状态下存储的内容不同。

在32位JVM中是这么存的(了解)

在64位JVM中的存储结构:

虽然它们在不同位数的JVM中长度不一样,但是基本组成内容是一致的。

  • 锁标志位(lock):区分锁状态,11时表示对象待GC回收状态, 只有最后2位锁标识(11)有效。
  • biased_lock:是否偏向锁,由于无锁和偏向锁的锁标识都是 01,没办法区分,这里引入一位的偏向锁标识位。
  • 分代年龄(age):表示对象被GC的次数,当该次数到达阈值的时候,对象就会转移到老年代。
  • 对象的hashcode(hash):运行期间调用System.identityHashCode()来计算,延迟计算,并把结果赋值到这里。当对象加锁后,计算的结果31位不够表示,在偏向锁,轻量锁,重量锁,hashcode会被转移到Monitor中。
  • 偏向锁的线程ID(JavaThread):偏向模式的时候,当某个线程持有对象的时候,对象这里就会被置为该线程的ID。 在后面的操作中,就无需再进行尝试获取锁的动作。
  • epoch:偏向锁在CAS锁操作过程中,偏向性标识,表示对象更偏向哪个锁。
  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。当锁获取是无竞争的时,JVM使用原子操作而不是OS互斥。这种技术称为轻量级锁定。在轻量级锁定的情况下,JVM通过CAS操作在对象的标题字中设置指向锁记录的指针。
  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。如果两个不同的线程同时在同一个对象上竞争,则必须将轻量级锁定升级到Monitor以管理等待的线程。在重量级锁定的情况下,JVM在对象的ptr_to_heavyweight_monitor设置指向Monitor的指针。

代码示例证明对象头:(借助JOL工具)

  1. 查看new一个Object对象的对象头

    字段说明
    OFFSET偏移量,也就是到这个字段位置所占用的byte数
    SIZE后面类型的字节大小
    TYPE是Class中定义的类型
    DESCRIPTIONDESCRIPTION是类型的描述
    VALUEVALUE是TYPE在内存中的值

    可以看到这里mark word占8byte(64bit),klass pointe 占4byte,另外剩余4byte是填充对齐的

    这是由于默认开启了指针压缩 ,klass pointe 占4byte(默认其实是占用8byte)

  2. 关闭指针压缩后,查看new一个Object对象的对象头。

    jdk8版本是默认开启指针压缩的,可以通过配置jvm参数开启关闭指针压缩,-XX:-UseCompressedOops

    如果关闭指针压缩重新打印对象的内存布局,可以发现总SIZE变大了,从下图中可以看到,对象头所占用的内存大小变为16byte(128bit),其中 mark word占8byte,klass pointe 占8byte,无对齐填充。

一般而言64位JDK8按照默认情况下,new一个对象占多少内存空间?

以下面的对象为例:其中int占4个字节,char占1个字节。

class MyObject{
    int i = 5;
    char a = 'a';
}

所以是 8(对象头)+ 8(类型指针,关闭指针压缩的情况) + 5 + 3(类型填充) = 24字节(虚拟机要求对象起始地址必须是8字节的整数倍。)

好的博客:Java对象的内存布局

四、synchronized与锁升级

4.1 synchronized关键字

方法上的 synchronized

class Test{
    public synchronized void test() {

    }
}
等价于
class Test{
    public void test() {
        synchronized(this) { // 普通synchronized方法相当于给当前类对象加锁

        }
    }
}
class Test{
    public synchronized static void test() {
    }
}
等价于
class Test{
    public static void test() {
        synchronized(Test.class) { // 静态synchronized方法,相当于给当前类的class对象加锁

        }
    }
}

private 或 final的重要性: 提高线程的安全性

  • 分析下面的程序:

    class ThreadSafe {
        public final void method1(int loopNumber) {
            ArrayList<String> list = new ArrayList<>();
            for (int i = 0; i < loopNumber; i++) {
                method2(list);
                method3(list);
            }
        }
        private void method2(ArrayList<String> list) {
            list.add("1");
        }
        public void method3(ArrayList<String> list) {
            list.remove(0);
        }
    }
    class ThreadSafeSubClass extends ThreadSafe{
        @Override
        public void method3(ArrayList<String> list) {
            new Thread(() -> {
                list.remove(0);
            }).start();
        }
    }
    

    本来ThreadSafe类为线程安全类,但由于子类ThreadSafeSubClass重写了method3()方法,导致ThreadSafe类不在线程安全。

    由于method3()方法为public, 此时子类可以重写父类的方法, 在子类中开线程来操作list对象, 此时就会出现线程安全问题: 子类和父类共享了list对象

总结:

  • 如果改为private, 子类就不能重写父类的私有方法, 也就不会出现线程安全问题; 所以所private修饰符是可以避免线程安全问题.
  • 所以如果不想子类, 重写父类的方法的时候, 我们可以将父类中的方法设置为private, final修饰的方法, 此时子类就无法影响父类中的方法了

4.2 synchronized的锁升级

4.2.1 偏向锁

为什么要引入偏向锁?

  • 因为经过HotSpot的作者大量的研究发现,大多数时候是不存在锁竞争的,常常是一个线程多次获得同一个锁,因此如果每次都要竞争锁会增大很多没有必要付出的代价,为了降低获取锁的代价,才引入的偏向锁。

偏向锁的升级:

  • 当线程1访问代码块并获取锁对象时,会在java对象头和栈帧中记录偏向的锁的threadID,因为偏向锁不会主动释放锁,因此以后线程1再次获取锁的时候,需要比较当前线程的threadID和Java对象头中的threadID是否一致。

    • 如果一致(还是线程1获取锁对象),表示偏向锁是偏向于当前线程的,则无需使用CAS来加锁、解锁了,直接进入同步;
    • 如果不一致,那么需要查看Java对象头中记录的线程1是否处于同步块中
      • 如果已经退出同步块,则将对象头设置成无锁状态并撤销偏向锁,重新偏向。
      • 如果处于同步块中,它还没有执行完,其它线程来抢夺,该偏向锁会被取消掉并出现锁升级。此时轻量级锁由原持有偏向锁的线程持有,继续执行其同步代码,而正在竞争的线程会进入自旋等待获得该轻量级锁。
  • 锁升级过程中Mark Word的改变。从无锁升级到偏向锁,Mark Word的后三位会从001变为101。并且Mark Word将执行持有偏向锁的线程id。

偏向锁相关jvm参数:

  • 偏向锁是默认开启的,但是默认偏向锁开始时间比应用程序启动有四秒的延迟

    • 可以使用 XX:BiasedLockingStartupDelay=0来禁用延迟
    • 可以使用 -XX:-UseBiasedLocking来禁止偏向锁

    jvm默认和偏向锁有关的参数

代码测试:

  • 禁用延迟之后可以看到,线程获取锁对象时,Mark Word的标志位变成了101

相关了解

  • 偏向锁的撤销情况。当调用对象的hashcode方法的时候就会撤销这个对象的偏向锁因为使用偏向锁时没有位置存hashcode的值了

  • 批量重偏向

    • 如果对象被多个线程访问,但是没有竞争 , 这时偏向T1的对象仍有机会重新偏向T2。重偏向会重置Thread ID
    • 当撤销偏向锁阈值超过 20 次后,jvm 会这样觉得,我是不是偏向错了呢,于是会在给这些对象加锁时重新偏向至加锁线程
  • 批量撤销偏向锁。当撤销偏向锁阈值超过 40 次后,jvm 会这样觉得,自己确实偏向错了,根本就不该偏向。于是整个类的所有对象都会变为不可偏向的,新建的对象也是不可偏向的

4.2.2 轻量级锁

轻量级锁的本质就是自旋锁

为什么要引入轻量级锁?

  • 轻量级锁考虑的是竞争锁对象的线程不多,而且线程持有锁的时间也不长的情景。因为阻塞线程需要CPU从用户态转到内核态,代价较大,如果刚刚阻塞不久这个锁就被释放了,那这个代价就有点得不偿失了,因此这个时候就干脆不阻塞这个线程,让它自旋这等待锁释放。

轻量锁的升级时机 : 当关闭偏向锁功能多线程竞争偏向锁会导致偏向锁升级为轻量级锁


轻量级锁什么时候升级为重量级锁?

  • 线程1获取轻量级锁时会先把锁对象的对象头MarkWord复制一份到线程1的栈帧中创建的用于存储锁记录的空间(Lock Record),然后使用CAS把对象头中的内容替换为线程1存储的锁记录的地址

    • 如果在线程1复制对象头的同时(在线程1CAS之前),线程2也准备获取锁,复制了对象头到线程2的锁记录空间(Lock Record)中,但是在线程2CAS的时候,发现线程1已经把对象头换了,线程2的CAS失败,那么线程2就尝试使用自旋锁来等待线程1释放锁

    • 但是如果自旋的时间太长也不行,因为自旋是要消耗CPU的,因此自旋的次数是有限制的,如果自旋次数到了线程1还没有释放锁,或者线程1还在执行,线程2还在自旋等待,这时又有一个线程3过来竞争这个锁对象,那么这个时候轻量级锁就会膨胀为重量级锁重量级锁把除了拥有锁的线程都阻塞,防止CPU空转。


自适应自旋锁

  • JDK 1.6引入了更加聪明的自旋锁,即自适应自旋锁。所谓自适应就意味着自旋的次数不再是固定的,它是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。

  • 线程如果自旋成功了,那么下次自旋的次数会更加多,因为虚拟机认为既然上次成功了,那么此次自旋也很有可能会再次成功,那么它就会允许自旋等待持续的次数更多。反之,如果对于某个锁,很少有自旋能够成功,那么在以后要或者这个锁的时候自旋的次数会减少甚至省略掉自旋过程,以免浪费处理器资源。

4.2.2.1 轻量级锁流程解释

轻量级锁加锁流程:

  1. 在获取轻量锁是会创建锁记录(Lock Record)对象,每个线程的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的Mark Word。

  2. 让锁记录中的Object reference指向锁对象地址,并且尝试用CAS将栈帧中的锁记录的(lock record 地址 00)替换Object对象的Mark Word,将Mark Word 的值(01)存入锁记录(lock record地址)------相互替换

    • 01 表示 无锁 (看Mark Word结构, 数字的含义)
    • 00 表示 轻量级锁

  3. 如果cas替换成功, 获得了轻量级锁,那么对象的对象头储存的就是锁记录的地址和状态00线程中锁记录, 记录了锁对象的锁状态标志; 锁对象的对象头中存储了锁记录的地址和状态, 标志哪个线程获得了锁

  4. 如果cas替换失败,有两种情况 : ① 锁膨胀 ② 执行了锁重入

    • 如果是其它线程已经持有了该Object的轻量级锁,那么表示有竞争,自旋一定的时间后,将进入锁膨胀阶段(膨胀我重量级锁)

    • 如果是自己的线程已经执行了synchronized进行加锁,那么再添加一条 Lock Record 作为重入锁的计数 – 线程多次加锁, 锁重入。


轻量级锁解锁流程:

  • 线程退出synchronized代码块的时候,如果获取的是取值为 null 的锁记录 ,表示有锁重入,这时重置锁记录,表示重入计数减一
  • 当线程退出synchronized代码块的时候,如果获取的锁记录取值不为 null,那么使用cas将Mark Word的值恢复给对象, 将直接替换的内容还原。
    • 成功则解锁成功 (轻量级锁解锁成功)
    • 失败,表示有竞争, 则说明轻量级锁进行了锁膨胀或已经升级为重量级锁进入重量级锁解锁流程 (Monitor流程)

轻量级锁膨胀流程:

  • 如果在尝试加轻量级锁的过程中,CAS 操作无法成功,这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁

  • 当 Thread-1 进行轻量级加锁时,Thread-0 已经对该对象加了轻量级锁, 此时发生锁膨胀

  • 这时Thread-1加轻量级锁失败,进入锁膨胀流程

    • 因为Thread-1线程加轻量级锁失败, 轻量级锁没有阻塞队列的概念, 所以此时就要为对象申请Monitor锁(重量级锁),让Object指向重量级锁地址 。
    • 然后自己进入Monitor 的EntryList 变成BLOCKED状态

  • 当 Thread-0 退出同步块解锁时,使用 cas 将 Mark Word 的值恢复给对象头,失败。这时会进入重量级解锁流程,即按照 Monitor 地址找到 Monitor 对象,设置 Owner 为 null,唤醒 EntryList 中 BLOCKED 线程

4.2.3 Monitor 原理 (重量级锁原理)

Monitor也称为监视器或者管程

每个Java对象都可以关联一个(操作系统的)Monitor,如果使用synchronized给对象上锁(重量级),该对象头的MarkWord中就被设置为指向Monitor对象的指针

下图原理解释:

  • 当Thread2访问到synchronized(obj)中的共享资源的时候

    • 首先会将synchronized中的锁对象对象头MarkWord去尝试指向操作系统Monitor对象. 让锁对象中的MarkWord和Monitor对象相关联. 如果关联成功, 将obj对象头中的MarkWord为指向重量级锁的指针,并且标志位变为10。

    • 因为Monitor没有和其他的obj的MarkWord相关联, 所以Thread2就成为了该Monitor的Owner(所有者)。

    • 又来了个Thread1执行synchronized(obj)代码, 它首先会看看能不能执行该临界区的代码; 它会检查obj是否关联了Montior, 此时已经有关联了, 它就会去看看该Montior有没有所有者(Owner), 发现有所有者了(Thread2); Thread1也会和该Monitor关联, 该线程就会进入到它的EntryList(阻塞队列);

    • Thread2执行完临界区代码后, Monitor的Owner(所有者)就空出来了. 此时就会通知Monitor中的EntryList阻塞队列中的线程, 这些线程通过竞争, 成为新的所有者

总结:

  • 刚开始时Monitor中的Owner为null
  • 当Thread-2 执行synchronized(obj){}代码时,首先会关联obj对象的Monitor,然后会将Monitor的所有者Owner 设置为 Thread-2,上锁成功,Monitor中同一时刻只能有一个Owner
  • 当Thread-2 占据锁时,如果线程Thread-3,Thread-4也来执行synchronized(obj){}代码,就会进入EntryList中变成BLOCKED状态
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的 (仍然是抢占式)
  • 图中 WaitSet 中的Thread-0,Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程,后面讲wait-notify 时会分析

注意:它加锁就是依赖底层操作系统的 mutex相关指令实现, 所以会造成用户态和内核态之间的切换, 非常耗性能 !

  • 在JDK6的时候, 对synchronized进行了优化, 引入了轻量级锁, 偏向锁, 它们是在JVM的层面上进行加锁逻辑, 就没有了切换的消耗

分析synchronized的字节码

Synchronized代码块同步在需要同步的代码块开始的位置插入monitorenter指令,在同步结束的位置或者异常出现的位置插入monitorexit指令;JVM要保证monitorentermonitorexit都是成对出现的,任何对象都有一个monitor与之对应,当这个对象的monitor被持有以后,它将处于锁定状态。

static final Object lock = new Object();
static int counter = 以上是关于Java并发编程——进程和线程Java对象内存布局synchronizedwait和notify的主要内容,如果未能解决你的问题,请参考以下文章

Java并发编程Thread详解

并发编程之java内存模型

Java 并发基础

JAVA的高并发编程

java并发编程基础概念

《一遍文章让你快速了解JAVA---并发编程基础》