zoukankan      html  css  js  c++  java
  • 从零开始学习Java多线程(三)

    本文主要对Java多线程同步与通信以及相关锁的介绍。

    1 .Java多线程安全问题

          Java多线程安全问题是实现并发最大的问题,可以说多线程开发其实就是围绕多线程安全问题开发,涉及之深,不是简简单单一两篇博客能够讲解清楚,如果想要更深层次认识多线程安全问题,需要自己查阅量更多资料,潜入书籍中去学习,作者和大家一样还在学习的路上。

          先通过一个例子认识Java多线程安全问题。

     1 public class MyThread {
     2 
     3     public static int count = 0;
     4 
     5     public static void main(String[] args) {
     6         // 保证所有线程执行完毕.
     7         final CountDownLatch cdl = new CountDownLatch(10);
     8         for (int i = 0; i < 10; i++)
     9             new Thread() {
    10                 public void run() {
    11                     for (int j = 0; j < 100; j++) {
    12                         count++;
    13                         try {
    14                             Thread.sleep(10);
    15                             System.out.println(count);
    16                         } catch (InterruptedException e) {
    17 
    18                         }
    19                     }
    20                     cdl.countDown();//cdl减1
    21                 }
    22             }.start();
    23 
    24         try {
    25             cdl.await();//等待一段时间直到cdl等于0,继续执行
    26         } catch (InterruptedException e) {
    27             e.printStackTrace();
    28         }
    29         System.out.println("static count: " + count);
    30     }
    31 }
    View Code

          定义一个等于0的变量count,开启10条线程,每条线程循环100次对count进行自增,正确的结果count=1000,运行后控制台输出:

         

         可以看出虽然最终结果正确,但控制台打印大量重复数据,再执行一次:

         

         可以看出这次执行结果并不是正确结果,而控制台同样输出大量重复数据。以上两次执行,无论是最终结果与否都存在线程安全问题,正确的执行结果不但执行过程不能出现重复数据,而且最终结果也必须是正确的,这就是多线程安全问题。

          为什么会出现多线程安全问题?问题出在哪里?究其根源发现之此处出现多线程安全问题是因为 count++并不是一个原子性操作,而是分为三步完成:(1) 从内存中读出count的值(2)执行加1操作 (3)重新对count赋值。只有经过这三步自增操作才完成,而在多线程环境下,可能出现第一条线程未完成赋值之前失去cpu时间片,第二条线程读取到的是第一条线程没有自增操作之前的数值,那么就会出现重复结果。

         上例只是一个简单的线程安全问题,但其具有线程不安全的所有主要因素,结合本例对线程不安全问题可以归纳为以下主要原因:

             a. 多线程环境(10条线程)

             b. 多线程环境存在共享数据 (count变量)

             c. 多线程对同一个共享数据操作  (count++)

         为了保证线程安全,Java采用同步机制以及引入锁的概念对共享数据的操作进行原子性封装,保证共享数据在一段代码内只能被一条线程处理,这样就可以避免count++因非原子性操作带来的线程安全问题。而锁是用来决定哪条线程能够进入操作共享数据的那段代码(被称为同步代码块),再执行完同步代码块之后释放锁,下一个获取到锁的线程才能再次进入同步代码块,以上就是Java保证线程安全的简单思路。

        Java提供了众多方式保证代码同步,最早出现,普遍使用就是关键字synchronized,它可以用来修饰方法和代码块,而synchronized修饰方法这种方式并不友好,它在保证线程安全的同时牺牲了效率,所以我们可以选择对共享数据操作的代码块使用synchronized修饰。synchronized需要配合锁的使用保证线程安全,锁具有互斥性,同时可分为对象锁和类锁,对象锁是指类的实例对象,synchronized修饰代码块时可以选取任意对象作为锁,修饰非静态方法时锁为类的实例对象,这两种锁均被成为对象锁;修饰静态方法时的锁为类的class对象,此时锁被称为类锁,两种锁均为互斥锁,但在某些方面具有不同的用途。

        使用synchronized对上例进行改造,保证线程安全的代码如下:

     1 public class MyThread {
     2 
     3     public static volatile int count = 0;
     4 
     5     private static final Object lock = new Object();
     6 
     7     public static void main(String[] args) {
     8         // 保证所有线程执行完毕.
     9         final CountDownLatch cdl = new CountDownLatch(10);
    10         for (int i = 0; i < 10; i++) {
    11             //加锁
    12             new Thread() {
    13                 public void run() {
    14                     synchronized (lock) {
    15                         for (int j = 0; j < 100; j++) {
    16                             count++;
    17                             try {
    18                                 Thread.sleep(100);
    19                                 System.out.println(count);
    20                             } catch (InterruptedException e) {
    21                                 //异常处理
    22                             }
    23                         }
    24                     }
    25                     cdl.countDown();//cdl减1
    26                 }
    27             }.start();
    28         }
    29         try {
    30             cdl.await();//等待一段时间直到cdl等于0,继续执行
    31         } catch (InterruptedException e) {
    32             e.printStackTrace();
    33         }
    34         System.out.println("static count: " + count);
    35     }
    36 }
    View Code

       再次执行结果为:

         可以看出与正确结果一致,此时多线程是安全的。

    2 .synchronized的性能优化

         synchronized作为官方推荐使用的同步关键字,其重要性不言而喻。最初synchronized的性能效率比较差,是不折不扣的重量级锁,但随着版本升级经过数次变革synchronized性能逐渐优化,我们来看下synchronized是怎么一步步优化的。

         synchronized字面意思同步的,重量级锁,对比Lock来说又是隐式锁,为什么成为隐式锁呢?上例实现代码同步的过程可以发现,我们知道加锁的位置,但并没有看到代码执行完毕释放锁的位置,这就是synchronized的特点,不需要开发人员关心在哪里释放锁,什么时候时候释放锁,synchronized自动完成锁的释放,而Lock锁则需要手动加锁和释放锁,所以其常被称为显式锁。还需要了解的是synchronized是JVM层面的,是作为一个关键字供开发使用的,而Lock则是JDK层面的,是作为一个接口供开发使用,大概这就是为什么官方推荐使用synchronized的原因吧。

       重量级锁

     synchronized为什么是重量级锁?这是因为锁的实现是依赖底层的监视器(暂不了解),监视器依赖操作系统底层的互斥锁,Java线程状态是内核态(操作系统)的映射,是Java特有的模型。当线程没有获取到锁,那么必将发生内核态和用户态(可以理解Java线程状态)的转换,操作系统线程状态转换的成本是很高的,所以synchronized效率比较低,被称为重量级锁。

       当前版本synchronized锁的状态共有四种 :

    • 无锁
    • 偏向锁
    • 轻量级锁
    • 重量级锁    

         很显然锁的性能从上到下依次降低,轻量级锁和偏向锁是为了尽可能的向无锁状态靠拢,尽可能减少重量。在介绍锁的状态之前需要了解两个概念Mark Word和CAS.

      Markword

         Java对象实例是由对象头和实例数据组成,对象头是由Markword和类型指针组成,如果为数组还会包括数组长度。简单理解对象头就是为了保存对象的一些必要信息,而Markword就是一种数据结构用来保存数据,随着JVM数位的不同,Markword也分为32bit和64bit。为了节省空间并不是每个字段都有空间,锁的状态不同,字段的含有也不相同。比如说32位的Markword,这几位是干什么的,别的几位是干什么的都代表不同的含义,在这里我们仅仅需要了解不同的锁状态在Markword中会记录不同的字段信息。

           锁标志位(Markword字段):他的标志位包括 无锁、偏向锁、轻量级锁、重量级锁

           轻量级锁时会记录:指向栈中锁记录的指针

           重量级锁时会记录:指向重量锁的指针

           偏向锁时记录:线程ID

       CAS

         compareAndSwap,比较与替换,它是一种实现并发算法常用的技术,CAS需要三个参数:内存地址V、旧的预期值A、即将更新的目标值B 。 当你对一个变量进行操作时,变量的初始值为A,你想要将它修改为B,当你修改后没有重新赋值之前,它会再次确定此时变量是否仍为A,如果是,那么完成修改,此时变量为B;如果不是,说明变量已经被修改为C,那么将对修改后的变量重新操作,以此循环。需要注意的是在此过程中并没有加锁,所以没有互斥访问但是能保证数据安全,可以理解为CAS只是逻辑上的加锁,避免了真正加锁带来的效率问题。这是CAS的核心理论,同时也是轻量级锁的底层实现。

       轻量级锁

         上面已经说过轻量级锁的实现是基于CAS操作,对于竞争不激烈的场景下,可以减少重量级所得使用。

         线程需要访问同步代码块时,会判断当前状态是否时无锁状态。如果无锁,尝试通过CAS操作,复制一份Mark Word并且将锁标记位修改位指向当前线程中锁记录的指针

               --修改成功,说明没有竞争,那么执行同步代码块

               --修改失败,说明存在竞争,那么锁会升级为重量级锁,Mark Word修改为指向重量级锁指针,此后请求锁的线程会被堵塞。

         当持有锁的线程执行结束后,会再次借助CAS操作恢复Mark Word:

               --恢复成功,说明此次CAS操作成功,锁释放完成

               --恢复失败,说明仍存在竞争,锁升级为重量级锁,修改Mark Word字段后,释放锁并且唤醒被堵塞的线程

          对于轻量级锁,核心就是CAS操作,通过比对Mark Word中锁标记位的新值和旧值后操作,CAS操作失败说明存在竞争,会自动升级为重量级锁,其他请求锁的线程被堵塞,该线程执行结束后唤醒其他堵塞线程。

       偏向锁

         对于轻量级锁,需要对Mark Word中复制的字段进行维护,已经多次CAS操作,但当场景中只有一条线程来回访问,那么轻量级锁的维护相对来说也没必要了,这样做也不是最优方式,而偏向锁就是一种优化方案。

         对于这种不但没有竞争而且总是一条线程来回访问,锁会偏向于这条线程,这也是偏向的概念,它的核心思想就是:锁会偏向第一个获取它的线程,如果不存在竞争,只有一个线程,则持有偏向锁的线程永远不需要同步。如果没有竞争,可以看到出来,偏向锁可以约等于是无锁。

         原理:当线程访问同步代码块,会记录存储锁偏向的线程ID,后续该线程在进入和退出时不再需要CAS操作进行加锁和解锁,只需简单地判断一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果不是当前线程ID,继续执行CAS操作,一旦CAS失败,锁会自动升级,然后执行同步代码块;如果成功,还是执行同步代码块。

        自旋性、适应性自旋

         所谓自旋,不是获取不到锁就堵塞,而是原地等待一会(时长和次数有限),再次尝试,以牺牲CPU为代价换取内核态和用户态转换的开销。

         适应性自旋则对自旋的限制,比如时长(或者次数限制)的一种优化,如果本次自旋成功,下次可以多等待一会,如果经常自旋失败,那就不需要自旋,直接堵塞。借助于适应性自旋,可以在CPU时间片的损耗和内核状态的切换开销之间相对的找到一个平衡,进而能够提高性能,从原来的一旦获取不到就阻塞、状态切换,转变为在有的时候可以借助于较小的CPU浪费避免状态切换的开销,所以显然可以提升性能。

        锁消除

         锁消除是指删除非必要的同步,根据代码逃逸技术,如果判断到一段代码中,堆上的数据不会逃逸出当前线程,那么可以认为这段代码是线程安全的,没必要加锁。

         比如方法A,调用B方法,B将内部创建的局部对象返回给A,那么这个局部变量就属于逃逸,存在被其他线程操作的可能。而锁消除是一种通过算法,将没有必要实现同步的代码消除synchronized取消同步。实际上JDK提供的方法,别人的jar包中有很多代码用到synchronized,所以你的代码中synchronized远比你想象中的多,锁消除就显得尤为重要了。

        锁粗化

         如一个A方法,中有三个对象b,c,d,分别调用他们的方法而且都是同步方法
          void A(){
            b.function();
            c.function();
            d.function();
        }  
        每个方法都加锁和解锁,是不是很烦很费电!如果他们碰巧使用的是同一把锁,其实大可将他们合并,减少加锁和解锁操作。也就是说,虚拟机检测到有一系列连串的对同一个对象加锁和解锁操作,就会将其合并成一次范围更大的加锁和解锁操作,如此必会减少加锁和解锁带来的消耗。
     
        结束:
          以上就是synchronized优化过的地方,从最初的重量级锁,这会小青年经历一次次优化已经成为一位可以独当一面的领袖,而且它自身有很多优势,比如隐式锁带来的方便,所以我们没有必要放弃使用它,除非场景特殊,或者对程序分析后,业务适合,否则尽可能的选择synchronized吧!
  • 相关阅读:
    Scons 三
    Scons 二
    vs code插件
    Scons一
    实例演示 C# 中 Dictionary<Key, Value> 的检索速度远远大于 hobbyList.Where(c => c.UserId == user.Id)
    ASP.NET Core 如何用 Cookie 来做身份验证
    如何设计出和 ASP.NET Core 中 Middleware 一样的 API 方法?
    小记编程语言的设计
    解决 VS2019 打开 edmx 文件时没有 Diagram 视图的 Bug
    一款回到顶部的 jQuery 插件,支持 Div 中的滚动条回到顶部
  • 原文地址:https://www.cnblogs.com/supiaol/p/10565508.html
Copyright © 2011-2022 走看看