java中的synchronized

什么是synchronized

synchronized是Java中的关键字,用于实现线程之间的同步。它提供了一种独占锁的机制,确保在同一个时刻,只有一个线程可以执行某个方法或者某个代码块(主要是对方法或者代码块中存在共享数据的操作),同时我们还应该注意到synchronized另外一个重要的作用,synchronized可保证一个线程的变化(主要是共享数据的变化)被其他线程所看到(保证可见性,完全可以替代Volatile功能),这点确实也是很重要的。

synchronized底层原理

synchronized关键字是Java中用于实现线程同步的一种机制,它基于Java对象的内置锁(Intrinsic Lock)实现线程之间的互斥访问。下面是synchronized实现原理的详细解释:

  1. 内置锁:每个Java对象都有一个内置锁,也称为监视器锁(Monitor Lock)。当某个线程执行synchronized代码块或方法时,它会自动获取该锁,其他线程需要等待锁的释放才能访问该对象。
  2. 互斥性:内置锁具有互斥性,即同一时间只有一个线程可以持有锁。如果一个线程已经持有锁并执行了synchronized代码块或方法,其他线程需要等待,无法直接进入执行。
  3. 锁的状态:内置锁主要包括两个状态,即无锁状态和锁定状态。
- 无锁状态:当一个Java对象被创建时,它处于无锁状态。此时,任何线程都可以直接访问该对象。
- 锁定状态:当某个线程成功获取该Java对象的内置锁时,该锁进入锁定状态。此时,其他线程需要等待,无法直接访问该对象。
  1. 操作系统调用:在Java虚拟机中,内置锁的实现依赖于操作系统提供的互斥原语(Mutex)。当线程试图获取锁时,它会发起系统调用来请求操作系统分配资源。
  2. 锁的释放:当持有锁的线程执行完synchronized代码块或方法时,该锁会被释放,从而允许其他线程继续访问该对象。

理解Java对象头与Monitor

在JVM中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充。如下:

img

  • 实例变量:存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
  • 填充数据:由于虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐,这点了解即可。

而对于顶部,则是Java头对象,它实现synchronized的锁对象的基础,这点我们重点分析它,一般而言,synchronized使用的锁对象是存储在Java对象头里的,jvm中采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 组成,其结构说明如下表:

虚拟机位数 头对象结构 说明
32/64bit Mark Word 存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bit Class Metadata Address 类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。

其中Mark Word在默认情况下存储着对象的HashCode、分代年龄、锁标记位等以下是32位JVM的Mark Word默认存储结构

锁状态 25bit 4bit 1bit是否是偏向锁 2bit 锁标志位
无锁状态 对象HashCode 对象分代年龄 0 01

由于对象头的信息是与对象自身定义的数据没有关系的额外存储成本,因此考虑到JVM的空间效率,Mark Word 被设计成为一个非固定的数据结构,以便存储更多有效的数据,它会根据对象本身的状态复用自己的存储空间,如32位JVM下,除了上述列出的Mark Word默认存储结构外,还有如下可能变化的结构:
img

重量级锁

重量级锁(Heavyweight Lock)是synchronized关键字对应的一种锁实现。它基于操作系统的互斥原语(Mutex)来实现线程之间的同步。下面详细解释重量级锁的工作原理:

  1. 锁的状态:重量级锁主要包括两个状态,即无锁状态和锁定状态。
  • 无锁状态:当线程刚开始执行synchronized代码块或方法时,锁处于无锁状态。这时,任何线程都可以竞争获取到锁。
  • 锁定状态:当某个线程成功获取到锁时,锁进入锁定状态。此时,其他线程需要等待,无法直接获取锁。
  1. 请求锁的过程:当一个线程试图获取重量级锁时,它会进行以下步骤:
  • 线程发起请求:线程通过申请锁的系统调用(如操作系统提供的mutex_lock()函数)向操作系统申请锁资源。
  • 操作系统加锁:操作系统检查锁的状态,如果锁处于锁定状态,当前线程进入阻塞状态,等待锁的释放。
  • 等待队列:操作系统将等待锁的线程加入到等待队列中,并暂停线程的执行。
  1. 锁的释放:当持有锁的线程执行完synchronized代码块或方法时,锁会被释放。这时,操作系统会重新调度等待队列中的线程来竞争获取锁。
  • 操作系统解锁:持有锁的线程通过系统调用(如mutex_unlock()函数)将锁标记为无锁状态。
  • 唤醒等待线程:操作系统从等待队列中选择一个线程唤醒,并让其继续执行。

重量级锁的特点和问题:

  • 上下文切换:获取重量级锁涉及线程的上下文切换,即从用户态切换到内核态,这需要较高的开销。因此,使用过多的重量级锁可能会影响系统性能。
  • 阻塞和唤醒:当线程无法获取锁时,它会进入阻塞状态并等待被唤醒。对于大量竞争锁的情况,会导致频繁的线程阻塞和唤醒,影响系统的吞吐量和响应时间。
  • 互斥性:重量级锁只允许一个线程同时持有锁,其他线程必须等待,这可能导致性能瓶颈。

因此,在设计多线程应用程序时,需要合理使用重量级锁,避免不必要的锁竞争和阻塞,从而提高系统的并发性能。

Java虚拟机对synchronized的优化

锁的状态总共有四种,无锁状态、偏向锁、轻量级锁和重量级锁。随着锁的竞争,锁可以从偏向锁升级到轻量级锁,再升级的重量级锁,但是锁的升级是单向的,也就是说只能从低到高升级,不会出现锁的降级,关于重量级锁,前面我们已详细分析过,下面我们将介绍偏向锁和轻量级锁以及JVM的其他优化手段,这里并不打算深入到每个锁的实现和转换过程更多地是阐述Java虚拟机所提供的每个锁的核心优化思想,毕竟涉及到具体过程比较繁琐,如需了解详细过程可以查阅《深入理解Java虚拟机原理》。

偏向锁

偏向锁是Java 6之后加入的新锁,它是一种针对加锁操作的优化手段,经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。但是对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。

轻量级锁

倘若偏向锁失败,虚拟机并不会立即升级为重量级锁,它还会尝试使用一种称为轻量级锁的优化手段(1.6之后加入的),此时Mark Word 的结构也变为轻量级锁的结构。轻量级锁能够提升程序性能的依据是“对绝大部分的锁,在整个同步周期内都不存在竞争”,注意这是经验数据。需要了解的是,轻量级锁所适应的场景是线程交替执行同步块的场合,如果存在同一时间访问同一锁的场合,就会导致轻量级锁膨胀为重量级锁。

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的时间,时间成本相对较高,因此自旋锁会假设在不久将来,当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

锁消除

消除锁是虚拟机另外一种锁的优化,这种优化更彻底,Java虚拟机在JIT编译时(可以简单理解为当某段代码即将第一次被执行时进行编译,又称即时编译),通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过这种方式消除没有必要的锁,可以节省毫无意义的请求锁时间,如下StringBuffer的append是一个同步方法,但是在add方法中的StringBuffer属于一个局部变量,并且不会被其他线程所使用,因此StringBuffer不可能存在共享资源竞争的情景,JVM会自动将其锁消除。

/**
 * Created by zejian on 2017/6/4.
 * Blog : http://blog.csdn.net/javazejian [原文地址,请尊重原创]
 * 消除StringBuffer同步锁
 */
public class StringBufferRemoveSync {

    public void add(String str1, String str2) {
        //StringBuffer是线程安全,由于sb只会在append方法中使用,不可能被其他线程引用
        //因此sb属于不可能共享的资源,JVM会自动消除内部的锁
        StringBuffer sb = new StringBuffer();
        sb.append(str1).append(str2);
    }

    public static void main(String[] args) {
        StringBufferRemoveSync rmsync = new StringBufferRemoveSync();
        for (int i = 0; i < 10000000; i++) {
            rmsync.add("abc", "123");
        }
    }

}

synchronized的可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功,在java中synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。如下:

public class AccountingSync implements Runnable{
    static AccountingSync instance=new AccountingSync();
    static int i=0;
    static int j=0;
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){

            //this,当前实例对象锁
            synchronized(this){
                i++;
                increase();//synchronized的可重入性
            }
        }
    }

    public synchronized void increase(){
        j++;
    }


    public static void main(String[] args) throws InterruptedException {
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(i);
    }
}

正如代码所演示的,在获取当前实例对象锁后进入synchronized代码块执行同步代码,并在代码块中调用了当前实例对象的另外一个synchronized方法,再次请求当前实例锁时,将被允许,进而执行方法体代码,这就是重入锁最直接的体现,需要特别注意另外一种情况,当子类继承父类时,子类也是可以通过可重入锁调用父类的同步方法。注意由于synchronized是基于monitor实现的,因此每次重入,monitor中的计数器仍会加1。

参考链接:添加链接描述

添加链接描述

你可能感兴趣的:(java,开发语言)