线程安全性问题
一、活跃性问题
死锁
饥饿
活锁
1.死锁:
经典例子:“哲学家进餐”问题。
死锁的解决:
可以看到在jconsole中,可以用这个工具检测死锁问题:
2.饥饿:
当线程无法访问它所需要的资源而不能继续执行时,就发生了饥饿现象。引发饥饿最常见的资源就是CPU时钟周期。如果在Java应用程序中对线程的优先级使用不当,或者在持有锁的时候执行一些无法结束的结构,那么也可能导致饥饿。
通常尽量不要更改线程的优先级,只要改变了线程的优先级,程序的行为就将与平台相关,并且会导致发生饥饿的风险。
饥饿与公平的几种情况
高优先级吞噬所有低优先级的cpu时间片。
线程被永久堵塞在一个等待进入同步块的状态。
等待的线程永远不会被唤醒。
3.活锁:
活锁是另一种形式的活跃性问题。该问题尽管不会阻塞线程,但也不能继续执行,因为线程将不断重复同样的操作,而且总会失败。活锁通常发生在处理事务消息中:如果不能成功处理某个消息,那么消息处理机制将回滚事务,并将它重新放到队列的开头。这样,错误的事务被一直回滚重复执行。这种形式的活锁通常是由过度的错误恢复代码造成的,因为它错误地将不可修复的错误认为是可修复的错误。
当多个相互协作的线程都对彼此进行相应而修改自己的状态,并使得任何一个线程都无法继续执行时,就导致了活锁。这就像两个过于礼貌的人在路上相遇:他们彼此让路,然后在另一条路上相遇,然后他们就一直这样避让下去。
要解决这种活锁问题,需要在重试机制中引入随机性。例如在网络上发送数据包,如果检测到冲突,都要停止并在一段时间后重发。如果都在1秒后重发,还是会冲突。所以引入随机性可以解决该类问题。
二、性能问题
三、线程安全性问题
字节码指令看安全性问题
public class Sequence { private int value; public int getNext() { return value++; } public static void main(String[] args) { Sequence sequence = new Sequence(); // while (true){ // System.out.println(sequence.getNext()); // } new Thread(new Runnable() { @Override public void run() { while (true) { System.out.println(Thread.currentThread().getName() + " " + sequence.getNext()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } }).start(); new Thread(new Runnable() { @Override public void run() { while (true) { System.out.println(Thread.currentThread().getName() + " " + sequence.getNext()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } }).start(); new Thread(new Runnable() { @Override public void run() { while (true) { System.out.println(Thread.currentThread().getName() + " " + sequence.getNext()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } }).start(); } }
为什么出现安全性问题: 我们javap 解析出字节码,看下getNext()方法
这个方法执行的时候会执行多条字节码指令,value是堆中的,也就是线程共享的,当某个线程执行这个code的时候,如果对value做了加操作,但是没执行11行,也就是没返回,这个时候其他的线程也进来了,也改了value,由于value没有进行同步,可能出现值一样。
何时发生安全性问题
解决方法: getNext()方法前加个synchronized关键字,就会解决这个安全性问题了。
四、解决线程安全性问题的方案
1.synchronized使用
/** * synchronized 放在普通方法上,内置锁就是当前的类的实例 * @return */ public synchronized int getNext() { return value++; } /** * 修饰静态方法,内置锁就是当前的Class字节码对象 * Sequence.class * @return */ public synchronized static int getPrevious(){ return value --; } /** * 修饰代码块 * synchronized (this),也可以是 * synchronized (Sequence.class) * @return */ public int xx(){ synchronized (this){ if (value > 0){ return value; }else{ return -1; } } }
偏向锁
轻量级锁
2.synchronized原理
<https://www.cnblogs.com/wuzhenzhao/p/10250801.html>
讲的太好了,推荐这个博客。
jdk1.6以后对synchronized锁进行了优化,包含偏向锁、轻量级锁、重量级锁;了解synchronized的原理我们需要明白3个问题:
1.synchronized是如何实现锁
2.为什么任何一个对象都可以成为锁
3.锁存在哪个地方?
Java对象头:
在Hotspot虚拟机中,对象在内存中的布局分为三块区域:对象头、实例数据和对齐填充;Java对象头是实现synchronized的锁对象的基础,一般而言,synchronized使用的锁对象是存储在Java对象头里。它是轻量级锁和偏向锁的关键
Mawrk Word:
Mark Word用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。Java对象头一般占有两个机器码(在32位虚拟机中,1个机器码等于4字节,也就是32bit),下面就是对象头的一些信息:
Monitor:
什么是Monitor?我们可以把它理解为一个同步工具,也可以描述为一种同步机制。所有的Java对象是天生的Monitor,每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。
到目前位置,对于锁存在哪个位置,我们已经清楚了,锁存在于每个对象的 markOop 对象头中.对于为什么每个对象都可以成为锁呢? 因为每个 Java Object 在 JVM 内部都有一个 native 的 C++ 对象 oop/oopDesc 与之对应,而对应的 oop/oopDesc 都会存在一个markOop 对象头,而这个对象头是存储锁的位置,里面还有对象监视器,即ObjectMonitor,所以这也是为什么每个对象都能成为锁的原因之一。那么 synchronized是如何实现锁的呢?
synchronized是如何实现锁:
了解了对象头以及monitor以后,接下来去分析synchronized的锁的实现,就会相对简单了。前面讲过synchronized的锁是进行过优化的,引入了偏向锁、轻量级锁;锁的级别从低到高逐步升级, 无锁->偏向锁->轻量级锁->重量级锁.锁的类型:锁从宏观上分类,分为悲观锁与乐观锁。
乐观锁:
乐观锁是一种乐观思想,即认为读多写少,遇到并发写的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(比较跟上一次的版本号,如果一样则更新),如果失败则要重复读-比较-写的操作。java中的乐观锁基本都是通过CAS操作实现的,CAS是一种更新的原子操作,比较当前值跟传入值是否一样,一样则更新,否则失败。
悲观锁:
悲观锁是就是悲观思想,即认为写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以每次在读写数据的时候都会上锁,这样别人想读写这个数据就会block直到拿到锁。java中的悲观锁就是Synchronized,AQS框架下的锁则是先尝试cas乐观锁去获取锁,获取不到,才会转换为悲观锁,如RetreenLock。
自旋锁(CAS):
自旋锁就是让不满足条件的线程等待一段时间,而不是立即挂起。看持有锁的线程是否能够很快释放锁。怎么自旋呢?其实就是一段没有任何意义的循环。虽然它通过占用处理器的时间来避免线程切换带来的开销,但是如果持有锁的线程不能在很快释放锁,那么自旋的线程就会浪费处理器的资源,因为它不会做任何有意义的工作。所以,自旋等待的时间或者次数是有一个限度的,如果自旋超过了定义的时间仍然没有获取到锁,则该线程应该被挂起。JDK1.6中-XX:+UseSpinning开启; -XX:PreBlockSpin=10 为自旋次数; JDK1.7后,去掉此参数,由jvm控制;
偏向锁:
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。
当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成01(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。执行同步块。这个时候线程2也来访问同步块,也是会检查对象头的Mark Word里是否存储着当前线程2的偏向锁,发现不是,那么他会进入 CAS 替换,但是此时会替换失败,因为此时线程1已经替换了。替换失败则会进入撤销偏向锁,首先会去暂停拥有了偏向锁的线程1,进入无锁状态(01).偏向锁存在竞争的情况下就回去升级成轻量级锁。
开启:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0 -client -Xmx1024m -Xms1024m
关闭:-XX:+UseBiasedLocking -client -Xmx512m -Xms512m
轻量级锁:
引入轻量级锁的主要目的是在多没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。当关闭偏向锁功能或者多个线程竞争偏向锁导致偏向锁升级为轻量级锁,则会尝试获取轻量级锁。
在代码进入同步块的时候,如果同步对象锁状态为无锁状态(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝,官方称之为 Displaced Mark Word。这个时候 JVM会尝试使用 CAS 将 mark Word 更新为指向栈帧中的锁记录(Lock Record)的空间指针。并且把锁标志位设置为 00(轻量级锁标志),与此同时如果有另外一个线程2也来进行 CAS 修改 Mark Word,那么将会失败,因为线程1已经获取到该锁,然后线程2将会进行 CAS操作不断的去尝试获取锁,这个时候将会引起锁膨胀,就会升级为重量级锁,设置标志位为 10.
由轻量锁切换到重量锁,是发生在轻量锁释放锁的期间,之前在获取锁的时候它拷贝了锁对象头的markword,在释放锁的时候如果它发现在它持有锁的期间有其他线程来尝试获取锁了,并且该线程对markword做了修改,两者比对发现不一致,则切换到重量锁。轻量级解锁时,会使用原子的CAS操作来将Displaced Mark Word替换回到对象头,如果成功,则表示同步过程已完成。如果失败,表示有其他线程尝试过获取该锁,则要在释放锁的同时唤醒被挂起的线程进入等待。
重量级锁:
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。主要是,当系统检查到锁是重量级锁之后,会把等待想要获得锁的线程进行阻塞,被阻塞的线程不会消耗cup。但是阻塞或者唤醒一个线程时,都需要操作系统来帮忙,这就需要从用户态转换到内核态,而转换状态是需要消耗很多时间的,有可能比用户执行代码的时间还要长。这就是说为什么重量级线程开销很大的。
monitor这个对象,在hotspot虚拟机中,通过ObjectMonitor类来实现 monitor。他的锁的获取过程的体现会简单很多。每个object的对象里 markOop->monitor() 里可以保存ObjectMonitor的对象。
所以这就是synchronized实现锁的一个过程。
3.单例模式和线程安全
//懒汉式(双重检查) class Singleton{ private static volatile Singleton instance; private Singleton(){} public static Singleton getInstance(){ if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } public class MulThread { public static void main(String[] args) { ExecutorService executorService = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { new Thread(()->{ Sington instance = Sington.getInstance(); System.out.println(instance); }).start(); } executorService.shutdown(); } }
volatile关键字为了防止出现指令重拍问题。双重检查可以解决饿汉式模式的安全问题。
运行结果:
4.重入锁
public class Demo { public synchronized void a() { System.out.println("a==="); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } public synchronized void b() { System.out.println("b==="); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } public static void main(String[] args) { Demo demo = new Demo(); new Thread( () -> { demo.a(); } ).start(); new Thread( () -> { demo.b(); } ).start(); new Thread( () -> { demo.a(); } ).start(); new Thread( () -> { demo.b(); } ).start(); } }
运行结果显示 每隔一秒打印,也就是始终拥有对象锁。
自旋锁(spinlock):是指当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。
获取锁的线程一直处于活跃状态,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
完