从零开始学习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 }
定义一个等于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 }
再次执行结果为:
可以看出与正确结果一致,此时多线程是安全的。
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远比你想象中的多,锁消除就显得尤为重要了。
锁粗化