Java并发学习(一)并发机制底层实现原理,线程安全、线程同步与synchronized关键字

什么是线程安全

当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。

什么是线程同步

当多个线程同时读写同一份共享资源的时候,可能会引起冲突。这时候,我们需要引入线程“同步”机制,即各位线程之间要有个先来后到,不能一窝蜂挤上去抢作一团。
同步这个词是从英文synchronize(使同时发生)翻译过来的。这是一个很容易引起误解的词。
线程同步的真实意思和字面意思恰好相反。线程同步的真实意思,其实是“排队”:几个线程之间要排队,一个一个对共享资源进行操作,而不是同时进行操作。

  1. 线程同步就是线程排队。同步就是排队。线程同步的目的就是避免线程“同步”执行。这可真是个无聊的绕口令。

  2. 只有共享资源的读写访问才需要同步。如果不是共享资源,那么就根本没有同步的必要。

  3. 只有“变量”才需要同步访问。如果共享的资源是固定不变的,那么就相当于“常量”,线程同时读取常量也不需要同步。至少一个线程修改共享资源,这样的情况下,线程之间就需要同步。
  4. 多个线程访问共享资源的代码有可能是同一份代码,也有可能是不同的代码;无论是否执行同一份代码,只要这些线程的代码访问同一份可变的共享资源,这些线程之间就需要同步。
  • 线程同步是实现线程安全的一种手段

synchronized 关键字

synchronized同步块是一种线程同步机制

锁像 synchronized 同步块一样,也是一种线程同步机制,但比 Java 中的 synchronized 同步块更复杂。因为锁(以及其它更高级的线程同步机制)是由 synchronized 同步块的方式实现的,所以我们还不能完全摆脱 synchronized 关键字(Java 5 之前的情况)。自 Java 5 开始,java.util.concurrent.locks 包中包含了一些锁的实现,因此你可以方便的去使用Java提供的锁了。

synchronized 实现同步的基础:Java中的每一个对象都可以作为锁,这些锁称为内置锁或监视器锁具体表现为:

  1. 对于普通同步方法(实例方法),锁是当前实例对象
  2. 对于静态同步方法,锁是当前类的Class对象
  3. 对于同步方法块,锁是synchronized 括号里的配置的对象

synchronized作用于实例方法

如下2个程序,第一段代码的结果为正确,第二段则不正确:

public class AccountingSync implements Runnable{
    //共享资源(临界资源)
    static int i=0;

    /**
     * synchronized 修饰实例方法
     */
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        AccountingSync instance=new AccountingSync();
        Thread t1=new Thread(instance);
        Thread t2=new Thread(instance);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }
    /**
     * 输出结果:
     * 2000000
     */
}

以上代码中synchronized修饰的是实例方法increase,在这样的情况下,当前线程的锁便是实例对象instance,Java中的线程同步锁可以是任意对象。从代码执行结果来看确实是正确的。

值得注意的是:当一个线程正在访问一个对象的 synchronized 实例方法,那么其他线程不能访问该对象的其他 synchronized 方法,毕竟一个对象只有一把锁,当一个线程获取了该对象的锁之后,其他线程无法获取该对象的锁,所以无法访问该对象的其他synchronized实例方法,但是其他线程还是可以访问该实例对象的其他非synchronized方法。

public class AccountingSyncBad implements Runnable{
    static int i=0;
    public synchronized void increase(){
        i++;
    }
    @Override
    public void run() {
        for(int j=0;j<1000000;j++){
            increase();
        }
    }
    public static void main(String[] args) throws InterruptedException {
        //new新实例
        Thread t1=new Thread(new AccountingSyncBad());
        //new新实例
        Thread t2=new Thread(new AccountingSyncBad());
        t1.start();
        t2.start();
        //join含义:当前线程A等待thread线程终止之后才能从thread.join()返回
        t1.join();
        t2.join();
        System.out.println(i);
    }
}

//输出为1465255

上述代码与前面不同的是我们同时创建了两个新实例AccountingSyncBad,然后启动两个不同的线程对共享变量i进行操作,所以结果是错误的;原因是上述代码犯了严重的错误,虽然我们使用synchronized修饰了increase方法,但却new了两个不同的实例对象,这也就意味着存在着两个不同的实例对象锁。

解决这种困境的的方式是将synchronized作用于静态的increase方法,这样的话,对象锁就当前类的Class对象,由于无论创建多少个实例对象,但对于类的Class对象拥有只有一个,所有在这样的情况下对象锁就是唯一的。

synchronized作用于静态方法

当synchronized作用于静态方法时,其锁就是当前类的class对象锁。由于静态成员不专属于任何一个实例对象,是类成员,因此通过class对象锁可以控制静态 成员的并发操作。需要注意的是如果一个线程A调用一个实例对象的非static synchronized方法,而线程B需要调用这个实例对象所属类的静态 synchronized方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的class对象,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

总之,Java的每一个对象都可以作为锁,只要访问的是同一个对象,那就受限于此对象的锁,不同的对象则不会相互干扰

synchronized同步代码块

synchronized同步代码块是比较常用的实现同步的方法,因为对于实例对象或静态calss类对象的锁定,导致锁作用的影响范围是很大的,即锁的粒度较大;这是并发编程中不希望看到的情况,因为锁粒度的增大意味着更多的线程需要等待对象的解锁,以及方法中不需要锁的部分操作也实现了同步,加锁解锁的过程造成了很大性能损耗。

此时,我们可以使用同步代码块的方式对需要同步的代码进行包裹,这样就无需对整个方法进行同步操作了

public class Main implements Runnable{
    static int i=0;
    @Override
    public void run() {
        //此部分省略了不需要同步的代码加锁的耗时操作....
        //使用同步代码块对变量i进行同步操作,锁对象为当前this即Main.class对象
        synchronized(this){
            for(int j=0;j<1000000;j++){
                i++;
            }
        }
    }
    public static void main(String[] args) throws InterruptedException {
        Main instance = new Main();
        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实现原理

synchronized底层实现原理

Java 虚拟机中的同步(Synchronization)基于进入和退出Monitor对象来实现方法同步和代码块同步,无论是显式同步(有明确的 monitorenter 和 monitorexit 指令,即同步代码块)还是隐式同步(方法同步)都是如此;方法的同步也可以使用这两个指令来实现

1. 对象锁

在 Java 中,每个对象都会有一个 monitor 对象,这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。类的对象可以有多个,所以每个对象有其独立的对象锁,互不干扰。

2. 类锁

在 Java 中,针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个 Class 对象,所以每个类只有一个类锁

Java对象头

Java对象在HotSpot虚拟机中的内存布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 实例数据部分:是对象真正存储的有效信息,存放类的属性数据信息,包括父类的属性信息,如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。

  • 对齐填充部分:由于虚拟机要求对象起始地址必须是8字节的整数倍,即对象的大小必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐。

  • 对象头

synchronized用的锁是存储在Java对象头里的,JVM采用2个字来存储对象头(如果对象是数组则会分配3个字,多出来的1个字记录的是数组长度),其主要结构是由Mark Word 和 Class Metadata Address 两个部分组成

虚拟机位数头对象结构说明
32/64bitMark Word存储对象的hashCode、锁信息或分代年龄或GC标志等信息
32/64bitClass Metadata Address类型指针指向对象的类元数据,JVM通过这个指针确定该对象是哪个类的实例。
32/32bitArray length数组的长度(如果当前对象是数组)

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

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

synchronized对象锁(重量级锁)的实现原理:

         锁对象的对象头的指针指向的是monitor对象(也称为管程或监视器锁)的起始地址。每个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如monitor可以与对象一起创建销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。

        在Java虚拟机(HotSpot)中,monitor是由ObjectMonitor实现的,其主要数据结构如下:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0;    //记录个数,获取锁加1,释放锁减一,为0代表锁被释放
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;  //指向当前持有monitor对象的线程
    _WaitSet      = NULL;  //处于wait状态的线程,会被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //处于等待锁block阻塞状态的线程,会被加入到该列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
  }

ObjectMonitor维护了两个队列,_WaitSet 和 _EntryList,用来保存ObjectWaiter对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner指向持有ObjectMonitor对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList 集合,当线程获取到对象的monitor 后进入 _Owner 区域并把monitor中的owner变量设置为当前线程同时monitor中的计数器count加1,若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1(此处的加减操作是利用CAS实现的原子操作,对于CAS下文有详解),同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)。如下图所示

由此看来,monitor对象存在于每个Java对象的对象头中(存储的指针的指向),synchronized锁便是通过这种方式获取锁的

值得注意的是:

  1. synchronized同步块对于同一线程来说是可重入的,即不会自己锁死自己
  2. 同步块在已进入的线程执行完之前,会阻塞后面其他线程的进入

这样会导致一个问题,因为Java的线程是映射在操作系统原生线程之上的,阻塞或唤醒一个线程,都需要操作系统帮忙完成,这就需要从用户态转换到核心态中,而这个状态之间的转换需要相对比较长的处理器时间,对此HotSpot虚拟机开发团队对synchronized锁进行了大量的优化措施。

CAS操作

什么是CAS

在使用锁时,线程获取锁是一种悲观锁策略,即假设每一次执行临界区代码都会产生冲突,所以当前线程获取到锁的时候同时也会阻塞其他线程获取该锁。

而CAS(compare and swap)又叫做比较交换操作是一种乐观锁策略,它假设所有线程访问共享资源的时候不会出现冲突,既然不会出现冲突自然而然就不会阻塞其他线程的操作。因此,线程就不会出现阻塞停顿的状态。通俗的说就是先进行操作,如果没有其他线程竞争共享数据就操作成功了;如果共享数据有争用,出现冲突了就重试当前操作直到没有冲突为止。

 CAS的操作过程

CAS可以通俗的理解为CAS(V,A,B)其中包含三个值分别是
V:内存地址中实际存放的值;
A:预期的值;
B:更新后的值。

    当且仅当V==A时,也就是说预期值和内存中实际的值相同,表明该值没有被其他线程更改过,即预期值A就是目前来说最新的值了,可以将B赋给V。
      当V!=A不相同,表明V值已经被其他线程改过了,即预期值A不是最新值了,所以不能将新值B赋给V,返回V值,将A值改为V。

  当多个线程使用CAS操作一个变量时,只有一个线程会成功并成功更新,其余会失败,失败的线程会重新尝试(自旋),当然也可以选择挂起线程(阻塞)。

CAS带来的问题

  1. CAS带来的ABA问题

  线程1准备用CAS将变量的值由A替换为B,在此之前,线程2将变量的值由A替换为C,又由C替换为A,然后线程1执行CAS时发现变量的值仍然为A,所以CAS成功。但实际上这时的现场已经和最初不同了。例如:

解决方案:
  atomic包中提供了AtomicStampedReference来解决ABA问题相当于添加了版本号

    2.自旋会浪费大量的处理器资源
  与线程阻塞相比,自旋会浪费大量的处理器资源。这是因为当前线程仍处于运行状况,只不过跑的是无用指令。它期望在运行无用指令的过程中,锁能够被释放出来。

  例如:阻塞相当于熄火停车,自旋状态相当于怠速停车。在十字路口,如果红绿灯等待的时间非常长,那么熄火相对省油一些;如果红绿灯的等待时间非常短,怠速停车更合适。

  然而,对于JVM来说,它并不能看到红灯的剩余时间,也就没法根据等待时间的长短来选择是自旋还是阻塞,JVM给出的方案是自适应自旋,根据以往自旋等待时能否获取锁,来动态调整自旋的时间。即就是如果在自旋的时候获取到锁,则会增加下一次自旋的时间,否则就稍微减小下一次自旋时长,对于我们的例子就是:如果之前不熄火等待了绿灯,那么这次不熄火的时间就长一点;如果之前不熄火没等待绿灯,那么这次不熄火的时间就短一点。

   3.CAS带来的公平性问题
  自旋状态还带来另外一个副作用,不公平的锁机制。处于阻塞状态的线程,无法立刻竞争被释放的锁。然而处于自旋状态的线程,则很有可能优先获得这把锁。内建锁无法实现公平机制
Lock锁可以

锁的优化

锁的状态从低到高一共有四种:无锁状态、偏向锁、轻量级锁和重量级锁。这几个状态会随着竞争情况逐渐升级,但锁不可降级,意味着锁从偏向锁升级到轻量级锁后不能再回到偏向锁级,这是为了提高获得和释放锁的效率。

1、偏向锁

经过研究发现,在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。

偏向锁使用了一种等待竞争出现才释放锁的机制

所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。

撤销偏向锁:以上撤销偏向锁时,需要等待全局安全点(在这个时间点上没有正在执行的字节码),然后暂停持有偏向锁的线程

关闭偏向锁:对于锁竞争比较激烈的场合,偏向锁就失效了,因为这样场合极有可能每次申请锁的线程都是不相同的,因此这种场合下不应该使用偏向锁,否则会得不偿失,需要注意的是,偏向锁失败后,并不会立即膨胀为重量级锁,而是先升级为轻量级锁。偏向锁在JVM是默认启动的,竞争激烈的情况下应该通过JVM参数关闭:UserBiasedLocking = false,那么程序默认进入轻量级锁状态。

轻量级锁

加锁:线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用于存储记录Lock Record的空间,并将对象头中的Mark Word复制到锁的记录中,官方称为Displaced Mark Word。然后线程尝试使用CAS将对象头中的Mark Word替换为指向 锁记录 的指针。如果成功替换,则当前线程获得锁;如果失败,表示其他线程竞争锁,当前线程便尝试用自旋来获取锁。

解锁:轻量级锁解锁时,会使用原子的CAS操作将Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁,即对象头指针指向对象监视器monitor(多个线程在相同时刻竞争同一把锁)。

å¨è¿éæå¥å¾çæè¿°

自旋锁

轻量级锁失败后,虚拟机为了避免线程真实地在操作系统层面挂起,还会进行一项称为自旋锁的优化手段。这是基于在大多数情况下,线程持有锁的时间都不会太长,如果直接挂起操作系统层面的线程可能会得不偿失,毕竟操作系统实现线程之间的切换时需要从用户态转换到核心态,这个状态之间的转换需要相对比较长的处理器时间,时间成本相对较高。

因此自旋锁会假设在不久将来当前的线程可以获得锁,因此虚拟机会让当前想要获取锁的线程做几个空循环(这也是称为自旋的原因),一般不会太久,可能是50个循环或100循环,在经过若干次循环后,如果得到锁,就顺利进入临界区。如果还不能获得锁,那就会将线程在操作系统层面挂起,这就是自旋锁的优化方式,这种方式确实也是可以提升效率的。最后没办法也就只能升级为重量级锁了。

自适应自旋锁:即自旋的时间不再固定,而是由上一次在同一个锁上的自旋时间和锁的拥有者的状态来决定的。自旋是需要消耗处理器时间的,自适应自旋避免了因锁被某线程长期持有而导致的其他线程自旋时间过长的问题。

synchronized原理整体流程

三种锁的特点

偏向锁:偏向锁只会在第一次请求锁时使用CAS操作,并在锁对象的标记字段中记录当前线程ID。在此后的运行过程中,持有偏向锁的线程无需加锁操作。针对的是锁仅会被同一线程持有的状况。
轻量级锁:轻量级锁采用CAS操作,将对象头中的Mark Word替换为指向锁记录的指针。针对的是多个线程在不同时间段申请同一把锁的情况。追求响应时间,同步块执行速度非常快。
重量级锁:重量级锁会阻塞、唤醒请求加锁的线程。针对的是多个线程同时竞争同一把锁的情况。JVM采用自适应自旋,来避免在面对非常小的同步代码块时,仍会被阻塞和唤醒的状况。追求吞吐量,同步块执行时间较长。

锁粗化

锁粗化就是将多次连接在一起的加锁、解锁操作合并为一次操作。将多个联系的锁扩展为一个范围更大的锁。

原则上,我们总是希望同步块的作用范围尽可能小一些-----只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待的线程也能尽快拿到锁。

但是有些情况下,一系列的连续操作都是在对同一个对象反复加锁解锁,甚至加锁操作是在循环体中,那加锁解锁带来的消耗将是巨大的;所以如果虚拟机检测到有一系列连串的对同一对象加锁和解锁操作,就会在在第一次操作时进行加锁,在最后一次方法操作结束后进行解锁。

锁消除

删除不必要的加锁操作,如果判断一段代码中,堆上的数据不会逃逸出当前线程,共享的数据不存在竞争时,则认为此代码是线程安全的,对锁进行消除(同步加锁无需进行)。

 

参考文章:

《深入理解Java虚拟机》

《Java并发编程的艺术》

https://blog.csdn.net/u012179540/article/details/40685207

https://blog.csdn.net/javazejian/article/details/72828483

https://blog.csdn.net/zhao_miao/article/details/84500771

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值