多线程并发 (三) 锁 synchronized、volatile

章节:
多线程并发 (一) 了解 Java 虚拟机 - JVM
多线程并发 (二) 了解 Thread
多线程并发 (三) 锁 synchronized、volatile
多线程并发 (四) 了解原子类 AtomicXX 属性地址偏移量,CAS机制
多线程并发 (五) ReentrantLock 使用和源码


通过前两篇学习引出两个问题 为什么会有锁?锁的作用是什么?
第一个问题如果真正理解了第一篇文章,对于为什么会有锁应该非常清晰了。这里总结一下我们知道java虚拟机栈是线程私有的,每当我们创建一个线程时JVM就会为此线程分配一个独立的内存空间并且拥有一个自己的程序计数器记录当前线程运行代码的指令地址(对应wait()/notify()线程被blocked阻塞之后又恢复运行),线程在做运算等操作时候都是在自己独立的内存中进行的,为了保证主内存中数据的安全性,进而就有了锁这个概念。关键答题点:Java内存模型 and Java虚拟机结构
第二个问题在本章将记录锁的作用。

1.Java内存模型

通过对问题一的解答,对于Java内存模型应该很清楚了大致如图:

Java 内存模型(Java Memory Model,JMM)规定了所有变量都存储在主内存中,每条线程都有自己的工作内存。
JVM 把内存划分成了好几块,其中方法区和堆内存区域是线程共享的。

2.线程安全的特性

  1. 原子性
    原子(Atomic)的字面意识是不可分割的,对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程看来是不可分割的,那么该操作就是原子操作,相应地称该操作具有原子性(Atomicity)。
  2. 可见性
    可见性是指一个线程对共享变量的更新,对于其他读取该变量的线程是否可见。
    可见性问题与计算机的存储系统有关,程序中的变量可能会被分配到寄存器而不是主内存中,每个处理器都有自己的寄存器,一个处理器无法读取另一个处理器的寄存器上的内容。
    从保证线程安全的角度来看,光保证原子性还不够,还要保证可见性,同时保证可见性和原子性才能确保一个线程能正确地看到其他线程对共享变量做的更新。

  3. 有序性
    在cpu优化之后,cpu对于任务的处理不是按照顺序来持行的,而是像多线程一样,当处理器在处理一条耗时的指令时这时也有可能继续持行下面的指令。
    重排序(Reordering)处理器和编译器是对代码做的一种优化,它可以在不影响单线程程序正确性的情况下提升程序的性能,但是它会对多线程程序的正确性产生影响,导致线程安全问题。

3.怎么保证线程的安全

要实现线程安全就要保证上面说到的原子性、可见性和有序性。
常见的实现线程安全的办法是使用锁和原子类型,而锁可分为内部锁、显式锁、读写锁、轻量级锁(volatile)四种。

  1. 内部锁
    synchronized 关键字来实现的叫做内部锁。
    特点:可重入,自动获取/释放,锁方法/对象/类/代码块,遇到异常会释放锁,非公平锁。
  2. 显示锁
    显式锁(Explict Lock)是 Lock 接口的实例,Lock 接口对显式锁进行了抽象,ReentrantLock 是它的实现类。
    特点:可重入,手动获取/释放,公平/非公平可设置。
  3. 读写锁
    锁的排他性使得多个线程无法以线程安全的方式在同一时刻读取共享变量,这样不利于提高系统的并发性,这也是读写锁出现的原因。
    特点:读锁共享,写锁排他
    private final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private final Lock readLock = readWriteLock.readLock();
    private final Lock writeLock = readWriteLock.writeLock();
    
    private void write1() {
      writeLock.lock();
      System.out.println("写线程1获取了写锁");
      try {
        System.out.println("写线程1开始执行操作");
        ThreadUtils.sleep(3 * 1000);
      } finally {
        writeLock.unlock();
        System.out.println("写线程1释放了写锁");
      }
    }
    
    private void read1() {
      readLock.lock();
      System.out.println("读线程1获取了读锁");
      try {
        System.out.println("读线程1开始执行操作");
        ThreadUtils.sleep(3 * 1000);
      } finally {
        readLock.unlock();
        System.out.println("读线程1释放了读锁");
      }
    }
    
    
  4. 轻量级锁volatile
    (看下面5)

内部锁和显示锁的区别:

  • 灵活性

    内部锁是基于代码的锁,锁的申请和释放只能在一个方法内执行,缺乏灵活性。
    显式锁是基于对象的锁,锁的申请和释放可以在不同的方法中执行,这样可以充分发挥面向对象编程的灵活性。

  • 锁调度策略
    内部锁只能是非公平锁。
    显式锁可以自己选择锁调度策略。

  • 便利性
    内部锁简单易用,不会出现锁泄漏的情况。
    显式锁需要自己手动获取/释放锁,使用不当的话会导致锁泄漏。

  • 阻塞
    如果持有内部锁锁的线程一直不释放这个锁,那其他申请这个锁的线程只能一直等待。
    显式锁 Lock 接口有一个 tryLock() 方法,当其他线程持有锁时,这个方法会返回直接返回 false。
    这样就不会导致线程处于阻塞状态,我们就可以在获取锁失败时做别的事情。

  • 适用场景
    在多个线程持有锁的平均时间不长的情况下我们可以使用内部锁
    在多个线程持有锁的平均较长的情况下我们可以使用显式锁(公平锁)

    摘学于:https://juejin.im/post/5d45a75de51d4561ee1bdf10

4. 锁 synchronized 

  1. synchronized 具有原子性,可见性。
  2. synchronized 是可重入锁的概念-代码体现
     
    class T {
    pravite synchronized void m (){
      mm(); // 同一个线程是可以调用的
     }
    
    privite synchronized void mm (){
    
     }
    }

    其实两个方法是同一把锁,锁的都是this对象。(同一个线程获得的锁是同一个锁,锁方法可以调锁方法,因为是同一个线程。如果是其他线程调用mm方法就不行,因为当前锁被当前线程占用着。)

  3. synchronized 锁的升级过程
    1)在早期jdk中synchronized是个重量级锁,每次加锁都需要向操作系统(OS)申请,代码运行效率非常低
    2)后来对synchronized 锁做了升级:
          1.当我们用synchronized 锁了一个Object对象之后,Object就会记录这个线程的id(叫做:偏向锁)
          2.如果出现了其他线程争用这个锁,就会升级为(自旋锁:段时间自旋等待)
          3.如果出现了大量的线程争用这个锁,这个锁就会升级为(重量级锁:向操作系统os申请)
          (自上而下效率逐渐降低)
    对于Java开发的同鞋在jvm调优这块可以对锁作出选择:对于何时使用自旋锁(jvm调优可以选择调节参数)何时选用重量级锁主要看你需要锁的 方法的运行时间,运行时间很长或者线程数量多的就用重量级锁(不在等待队列不占用cpu),否则就选用自旋锁(等待占用cpu)操作时间短。(马士兵:我就是厕所所长1/2)
  4. synchronized 锁升级过程在 JVM hotspot 中的体现
    1)synchronized 在字节码中的指令是moniterEnter 对应 moniterExit 表示进入和退出
    2)对象的创建过程(new的过程)
            三步:开辟空间创建属性对象赋默认值,调用构造方法赋初始值,给栈里的对象赋引用。(cpu指令重排提高效率)
    3)对象在内存中的存储布局 (第一篇有讲过,这里结合锁再引用一遍)

    对象头(markword)中的锁

    过程描述:一个对象创建之后包括四个部分如上图,其中markword对象头中保存了对象的hashCode,分代年龄,锁标志。
    1)new出来的对象还是个无锁的状态,当有一个线程访问时候,该锁就升级成了偏向锁/这时的hashCode就被转移到了该对象的stack栈空间中,对象的markword中保存当前线程id等,此时偏向锁标志被标记为1,。
    2)如果这时出现了另外一个线程在等待此对象的锁,那么这个锁就升级成了轻量及锁(自旋锁:一直在for循环等待试探是否可以获取锁,消耗cpu)如果自旋超过10次,该锁就被升级为重量级锁。
    3)重量级锁是操作系统OS处理的,重量级锁会把当前等待的线程丢到等待队列中去,等锁释放了再从队列中拿出来。
    4)对象头中不仅包含了锁的升级,还包含了对象的GC过程,GC每回收一次分代年龄就会加1 当gc年龄到达6岁对象就升到老年代区域。
  5. wait()时候当前线程占有的锁就被释放了。但是程序是停在了当前wait()处,当notify()之后代码还在wait()处向下持行。
    能保证我们当前线程当接受到notify之后还在wait处向下持行的是Jvm中的程序计数器。
  6. 上篇老铁问了个问题记录一下

5. volatile

  1. volatile 具有有序性,可见性。
  2. 一定要注意volatile并不保证原子性,也就是说如果 volatile 修饰的变量的操作不具有原子性,可能会出错的。下面针对volatile的两个特性进行举例
  3. volatile 可见性 错误理解举例:
    static volatile int i =0;
    
        private void add (){
            i ++;
        }
    
        public static void main() {
            final CountDownLatch latch = new CountDownLatch(20);
    
            for (int t=0;t<20;t++){
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        for (int y = 0;y<10000;y++){
                            add();
                        }
                        latch.countDown();
                    }
                }).start();
            }
    
            try {
                latch.await();
                Log.e("wxy--","------" +i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
    输出结果:
    2019-12-31 15:11:22.815 15908-15930/? E/wxy--: ------158157
    2019-12-31 15:11:27.195 16018-16039/? E/wxy--: ------197659
     

    阅读代码可知,整块代码创建了20个线程,然后每个线程让 race 变量自增10000次,但最终得到的结果小于200000。为什么?我们知道volatile修饰的属性,对于其他线程来说是可见的,就比如我们线程 A 修改了 race ,然后线程B 确实也立即可见了修改后的 race 的值,这写过程并没有错。有误解的地方就在于 (race ++)这个操作他不是原子操作。

    对于 race ++ 我们可以转换成一下几步:
    1、从内存读到寄存器:a = race;
    2、寄存器自增     :race = race +1;
    3、回内存        :把a读到内存
    (个人理解,请到评论席教训我 哈哈~)
    

    第一步保证了volatile的可见性,当我们从内存中读取之后,这个值可能这时候就被其他线程增加了,当我们再读回内存时候就重复了。所以要保证race的结果还是要加 锁 才行。该后:

       private synchronized void add (){
            i ++;
        }

    结果正确

  4. volatile 有序性 防止指令重排序理解举例:
    到这里我们对一个对象的创建过程是非常熟悉了,简单三部:

    1、开辟空间创建属性对象赋默认值
    2、调用构造方法赋初始值
    3、给栈里的对象赋引用

    那现在我们要创建一个单例对象如图:

    问题引入:在这个单例对象中 INSTANCE 对象需要加 volatile 修饰吗?
    1、我们要知道cpu是有重排序和乱序持行的优化操作,也就是我们创建对象的顺序 1、2、3可能3、2、1。
    2、如果cpu先持行了指令3 那么我们这时另外一个线程代码刚好走到了地一个判断处,那么就会直接走return。
    3、但是这时候我们得到的对象的内部数据还是空的,有些参数还只是默认值,还没赋初始值。
    所以这里需要加 volatile 防止 指令重排序。
    深入理解Jvm中还提到了另外 一个例子共学习:

    记录下来思考学习

  5. volatile 底层是采用内存屏障实现的

    I. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内

    存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

    II. 它会强制将对缓存的修改操作立即写入主存;

    III. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

下篇学习原子类型,reentrantlock,死锁等。

这些篇文章都是写的概念,相对的实例代码比较少,对于基础薄弱的还是要先去了解用法,然后在来学习对应的概念。只有会使用了,又了解了对应的概念原理,才能让我们更好的使用 锁,开发过程中才更轻松,遇到问题才能应变自如。

由于本人记性不好学者忘着,所以才一字一句的记录下来,若有错误或其他问题请评论区教训我~。

摘学于:
深入理解Java虚拟机

发布了119 篇原创文章 · 获赞 140 · 访问量 18万+

猜你喜欢

转载自blog.csdn.net/WangRain1/article/details/103770264