从零开始学习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吧!
posted @ 2019-04-18 11:37  不忘长安  阅读(201)  评论(0编辑  收藏  举报