什么是线程安全性?在《Java并发编程实战》中,定义如下:
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在调用代码中不需要任何额外的同步或者协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。
实现好的并发是一件困难的事情,所以很多时候我们都想躲避并发。避免并发最简单的方法就是线程封闭。什么是线程封闭呢?就是把对象封装到一个线程里,只有这一个线程能看到此对象。那么这个对象就算不是线程安全的也不会出现任何安全问题。实现线程封闭有哪些方法呢?
ad-hoc线程封闭:
这是完全靠实现者控制的线程封闭,他的线程封闭完全靠实现者实现。Ad-hoc线程封闭非常脆弱,应该尽量避免使用。
栈封闭:
栈封闭是我们编程当中遇到的最多的线程封闭。什么是栈封闭呢?简单的说就是局部变量。多个线程访问一个方法,此方法中的局部变量都会被拷贝一份到线程栈中。所以局部变量是不会被多个线程所共享的,也就不会出现并发问题。所以能用局部变量就别用全局的变量,全局变量容易引起并发问题。
没有任何成员变量的类,就叫无状态的类,这种类一定是线程安全的。如果这个类的方法参数中使用了对象,也是线程安全的吗?比如:
public class StatelessClass {
public int service(int a,int b){
return a+b;
}
public void serviceUser(UserVo user){
//do sth user
}
}
当然也是,因为多线程下的使用,固然user这个对象的实例会不正常,但是对于StatelessClass这个类的对象实例来说,它并不持有UserVo的对象实例,它自己并不会有问题,有问题的是UserVo这个类,而非StatelessClass本身。
让状态不可变,两种方式:
public class ImmutableClass {
private final int a;
public int getA() {
return a;
}
public UserVo getUser() {
return user;
}
public ImmutableClass(int a) {
this.a = a;
}
public static class User{
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
}
public class ImmutableClassToo {
private final List list = new ArrayList<>(3);
public ImmutableClassToo() {
list.add(1);
list.add(2);
list.add(3);
}
public boolean isContain(int i){
return list.contains(i);
}
}
但是要注意,一旦类的成员变量中有对象,上述的final关键字保证不可变并不能保证类的安全性,为何?因为在多线程下,虽然对象的引用不可变,但是对象在堆上的实例是有可能被多个线程同时修改的,没有正确处理的情况下,对象实例在堆中的数据是不可预知的。这就牵涉到了如何安全的发布对象这个问题。
public class ImmutableClass {
private final int a;
private final UserVo user = new UserVo(); //不安全
public int getA() {
return a;
}
public UserVo getUser() {
return user;
}
public ImmutableClass(int a) {
this.a = a;
}
public static class User{
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
}
类中持有的成员变量,如果是基本类型,发布出去,并没有关系,因为发布出去的其实是这个变量的一个副本,参见代码:
public class SafePublish {
private int i;
public SafePublish() {
i = 2;
}
public int getI() {
return i;
}
public static void main(String[] args) {
SafePublish safePublish = new SafePublish();
int j = safePublish.getI();
System.out.println("before j="+j);
j = 3;
System.out.println("after j="+j);
System.out.println("getI = "+safePublish.getI());
}
}
但是如果类中持有的成员变量是对象的引用,如果这个成员对象不是线程安全的,通过get等方法发布出去,会造成这个成员对象本身持有的数据在多线程下不正确的修改,从而造成整个类线程不安全的问题。参见以下代码可以看见:
public class UnSafePublish {
private List list = new ArrayList<>(3);
public UnSafePublish() {
list.add(1);
list.add(2);
list.add(3);
}
public List getList() {
return list;
}
public static void main(String[] args) {
UnSafePublish unSafePublish = new UnSafePublish();
List list = unSafePublish.getList();
System.out.println(list);
list.add(4);
System.out.println(list);
System.out.println(unSafePublish.getList());
}
}
这个list发布出去后,是可以被外部线程之间修改,那么在多个线程同时修改的情况下不安全问题是肯定存在的,怎么修正这个问题呢?我们在发布这对象的时候,就应该用线程安全的方式包装这个对象。我们将list用Collections.synchronizedList进行包装以后,无论多少线程使用这个list,就都是线程安全的了。
private List list = Collections.synchronizedList(new ArrayList<>(3));
对于我们自己使用或者声明的类,JDK自然没有提供这种包装类的办法,但是我们可以仿造这种模式或者委托给线程安全的类,当然,对这种通过get等方法发布出去的对象,最根本的解决办法还是应该在实现上就考虑到线程安全问题。
ThreadLocal是实现线程封闭的最好方法。ThreadLocal内部维护了一个Map,Map的key是每个线程的名称,而Map的值就是我们要封闭的对象。每个线程中的对象都对应着Map中一个值,也就是ThreadLocal利用Map实现了对象的线程封闭。
不是线程安全的类,为什么我们平时没感觉到:
1、在需求上,很少有共享的需求。
2、接收到了请求,返回应答的时候,一般都是由一个线程来负责的。但是只要Servlet中有成员变量,一旦有多线程下的写,就很容易产生线程安全问题。
死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁。
举个例子:A和B去按摩洗脚,都想在洗脚的时候,同时顺便做个头部按摩,13技师擅长足底按摩,14擅长头部按摩。这个时候A先抢到14,B先抢到13,两个人都想同时洗脚和头部按摩,于是就互不相让,扬言我死也不让你,这样的话,A抢到14,想要13,B抢到13,想要14,在这个想同时洗脚和头部按摩的事情上A和B就产生了死锁。怎么解决这个问题呢?
第一种,假如这个时候,来了个15,刚好也是擅长头部按摩的,A又没有两个脑袋,自然就归了B,于是B就美滋滋的洗脚和做头部按摩,剩下A在旁边气鼓鼓的,这个时候死锁这种情况就被打破了,不存在了。
第二种,C出场了,用武力强迫A和B,必须先做洗脚,再头部按摩,这种情况下,A和B谁先抢到13,谁就可以进行下去,另外一个没抢到的,就等着,这种情况下,也不会产生死锁。
所以总结一下:
死锁是必然发生在多操作者(M>=2个)情况下,争夺多个资源(N>=2个,且N<=M)才会发生这种情况。很明显,单线程自然不会有死锁,只有B一个去,不要2个,打十个都没问题;单资源呢?只有13,A和B也只会产生激烈竞争,打得不可开交,谁抢到就是谁的,但不会产生死锁。同时,死锁还有一个重要的要求,争夺资源的顺序不对,如果争夺资源的顺序是一样的,也不会产生死锁。
死锁的发生必须具备以下四个必要条件:
理解了死锁的原因,尤其是产生死锁的四个必要条件,就可以最大可能地避免、预防和解除死锁。只要打破四个必要条件之一就能有效预防死锁的发生:
打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。
避免死锁常见的算法有有序资源分配法、银行家算法。
在我们IT世界有没有存在死锁的情况,有:数据库里多事务而且要同时操作多个表的情况下。所以数据库设计的时候就考虑到了检测死锁和从死锁中恢复的机制。比如oracle提供了检测和处理死锁的语句,而mysql也提供了“循环依赖检测的机制”
在Java世界里存在着多线程争夺多个资源,不可避免的存在着死锁。那么我们在编写代码的时候什么情况下会发生呢?
简单顺序死锁,参见代码 NormalDeadLock:
// 先拿第一个锁,再拿第二个锁
private static void fisrtToSecond() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized(valueFirst) {
System.out.println(threadName + "get first lock end!");
Thread.sleep(100);
synchronized(valueSecond) {
System.out.println(threadName+" get second lock end!");
}
}
}
//先拿第二个锁,再拿第一个锁(产生死锁,可以通过调整锁的顺序解决)
private static void SecondToFisrt() throws InterruptedException {
String threadName = Thread.currentThread().getName();
synchronized (valueFirst){
System.out.println(threadName+" get first lock end");
Thread.sleep(100);
synchronized (valueSecond){
System.out.println(threadName+" get second lock end");
}
}
}
动态顺序死锁:顾名思义也是和获取锁的顺序有关,但是比较隐蔽,不像简单顺序死锁,往往从代码一眼就看出获取锁的顺序不对。参见代码com.chj.thread.capt08.transfer.UserAccount。
实际工作中的死锁时间不定,不是每次必现;一旦出现没有任何异常信息,只知道这个应用的所有业务越来越慢,最后停止服务,无法确定是哪个具体业务导致的问题;测试部门也无法复现,并发量不够。
定位:要解决死锁,当然要先找到死锁,怎么找?
首先打开控制台,cd C:\Program Files\Java\jdk1.8.0_131\bin目录下通过jps 查询应用的 id,再通过jstack id 查看应用的锁的持有情况:
关键是保证拿锁的顺序一致,两种解决方式:
1)内部通过顺序比较,确定拿锁的顺序;
2)采用尝试拿锁的机制。参见代码:
public class SafeOperate implements ITransfer{
// 第三把锁
private static Object tieLock = new Object();
@Override
public void transfer(UserAccount from, UserAccount to, int amount) throws InterruptedException {
int fromHash = System.identityHashCode(from);
int toHash = System.identityHashCode(to);
if(fromHash < toHash) {
synchronized(from) {
System.out.println(Thread.currentThread().getName()+" get "+from.getName());
Thread.sleep(100);
synchronized (to){
System.out.println(Thread.currentThread().getName()+" get "+to.getName());
from.reduceMoney(amount);
to.addMoney(amount);
System.out.println("from=="+from);
System.out.println("to==="+to);
}
}
}else if(fromHash > toHash) {
synchronized(to) {
System.out.println(Thread.currentThread().getName()+" get "+to.getName());
Thread.sleep(100);
synchronized (from){
System.out.println(Thread.currentThread().getName()+" get "+from.getName());
from.reduceMoney(amount);
to.addMoney(amount);
System.out.println("from=="+from);
System.out.println("to==="+to);
}
}
}else {
synchronized(tieLock) {
synchronized(from) {
synchronized(to) {
from.reduceMoney(amount);
to.addMoney(amount);
System.out.println("from=="+from);
System.out.println("to==="+to);
}
}
}
}
}
}
和SafeOperateToo:
public class SafeOperateToo implements ITransfer {
@Override
public void transfer(UserAccount from, UserAccount to, int amount) throws InterruptedException {
Random r = new Random();
while(true) {
if(from.getLock().tryLock()) {
System.out.println(Thread.currentThread().getName() +" get"+from.getName());
try {
System.out.println(Thread.currentThread().getName() +" get"+to.getName());
from.reduceMoney(amount);
to.addMoney(amount);
System.out.println(from);
System.out.println(to);
break;
}finally {
from.getLock().unlock();
}
}
}
}
}
两个线程在尝试拿锁的机制中,发生多个线程之间互相谦让,不断发生同一个线程总是拿到同一把锁,在尝试拿另一把锁时因为拿不到,而将本来已经持有的锁释放的过程。解决办法:每个线程休眠随机数,错开拿锁的时间。
我们做应用的时候:
1)先保证程序正确,确实达不到要求的时候,再提高速度。(黄金原则)
2)一定要以测试为基准。
如果主线程是唯一的线程,那么它基本上不会被调度出去。另一方面,如果可运行的线程数大于CPU的数量,那么操作系统最终会将某个正在运行的线程调度出来,从而使其他线程能够使用CPU。这将导致一次上下文切换,在这个过程中将保存当前运行线程的执行上下文,并将新调度进来的线程的执行上下文设置为当前上下文。上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。
切换上下文需要一定的开销,而在线程调度过程中需要访问由操作系统和JVM共享的数据结构。应用程序、操作系统以及JVM都使用一组相同的CPU。在JVM和操作系统的代码中消耗的CPU时钟周期越多,应用程序的可用CPU时钟周期就越少,但上下文切换的开销并不只是包含JVM和操作系统的开销。当一个新的线程被切换进来时,它所需要的数据可能不在当前处理器的本地缓存中,因此上下文切换将导致一些缓存缺失,因而线程在首次调度运行时会更加缓慢。
当线程由于等待某个发生竞争的锁而被阻塞时,JVM通常会将这个线程挂起,并允许它被交换出去。如果线程频繁地发生阻塞,那么它们将无法使用完整的调度时间片。在程序中发生越多的阻塞(包括阻塞IO,等待获取发生竞争的锁,或者在条件变量上等待)与CPU密集型的程序就会发生越多的上下文切换,从而增加调度开销,并因此而降低吞吐量。
上下文切换是计算密集型操作。也就是说,它需要相当可观的处理器时间。所以上下文切换对系统来说意味着消耗大量的CPU时间,事实上可能是操作系统中时间消耗最大的操作。上下文切换的实际开销会随着平台的不同而变化,然而按照经验来看,在大多数通用的处理器中上下文切换的开销相当于50~10000个时钟周期,也就是几微秒。
UNIX系统的 vmstat命令能报告上下文切换次数以及在内核中执行时间所占比例等信息。如果内核占用率较高(超过10%),那么通常表示调度活动发生得很频繁,这很可能是由IO或竞争锁导致的阻塞引起的。
同步操作的性能开销包括多个方面,在 synchronized和 volatile提供的可见性保证中可能会使用一些特殊指令,即内存栅栏( Memory Barrier)。内存栅栏可以刷新缓存,使缓存无效刷新硬件的写缓冲以及停止执行管道。内存栅栏可能同样会对性能带来间接的影响,因为它们将抑制一些编译器优化操作。在内存栅栏中大多数操作都是不能被重排序的。
引起阻塞的原因:包括阻塞IO等待获取发生竞争的锁,或者在条件变量上等待等等。
阻塞会导致线程挂起【挂起:挂起进程在操作系统中可以定义为暂时被淘汰出内存的进程,机器的资源是有限的,在资源不足的情况下,操作系统对在内存中的程序进行合理的安排,其中有的进程被暂时调离出内存,当条件允许的时候,会被操作系统再次调回内存,重新进入等待被执行的状态即就绪态,系统在超过一定的时间没有任何动作】。
很明显这个操作至少包括两次额外的上下文切换,还有相关的操作系统级的操作等等。
原理:在getInstance()方法中,进行两次null检查。这样可以极大提升并发度,进而提升性能。
public class SingletonDoubleCheck {
//TODO 注意如果不适用volatile 关键字无法保证线程安全
private volatile static SingletonDoubleCheck sDoubleCheck;
private SingletonDoubleCheck() {}
public static SingletonDoubleCheck getInstance() {
//第一次检查,不加锁
if(sDoubleCheck == null) {
System.out.println(Thread.currentThread()+" is null");
synchronized(SingletonDoubleCheck.class) {
//第二次检查,加锁情况下
if(sDoubleCheck == null) {
System.out.println(Thread.currentThread()+" is null");
//内存中分配空间 1
//空间初始化 2
//把这个空间的地址给我们的引用 3
sDoubleCheck = new SingletonDoubleCheck();
}
}
}
return sDoubleCheck;
}
}
解决办法,加volatile关键字。
原理:通过一个静态内部类定义一个静态变量来持有当前类实例,在类加载时就创建好,使用时获取。
缺点:无法做到延迟创建对象,在类加载时进行创建会导致初始化时间变长。
public class LazySingleton {
//TODO 通过private修饰,就是为了防止这个类被随意创建
private LazySingleton(){}
//TODO 利用虚拟机的类初始化机制创建单例
private static class SingletonHolder {
private static LazySingleton instance = new LazySingleton();
}
//TODO
public static LazySingleton getInstance() {
return SingletonHolder.instance;
}
}
原理:类延迟创建,在单例类的内部由一个私有静态内部类来持有这个单例类的实例,延迟占位模式还可以用在多线程下实例域的延迟赋值。
缺点:由于synchronized的存在,多线程时效率很低。
public class LazySingletonSynchronized {
//TODO 通过private修饰,就是为了防止这个类被随意创建,只有内部自己可以创建
private LazySingletonSynchronized(){
System.out.println("LazySingleton is created!!");
}
// TODO 首先instance对象必须是private并且static,如果不是private那么instance的安全无法得到保证
//TODO 其次因为工厂方法必须是static,因此变量instance也必须是static
private static LazySingletonSynchronized instance = null;
// TODO 为了防止对象被多次创建,使用synchronized关键字进行同步(缺点是并发环境下加锁,竞争激励的场合可能对性能产生一定影响)
public static synchronized LazySingletonSynchronized getInstance() {
if(instance == null) {
instance = new LazySingletonSynchronized();
}
return instance;
}
}
原理:在声明的时候就new这个类的实例,因为在JVM中,对类的加载和类初始化,由虚拟机保证线程安全,每次要用时直接返回。
缺点:无法做到延迟创建对象,在类加载时进行创建会导致初始化时间变长。
public class SingletonSynchronized {
//TODO 通过private修饰,就是为了防止这个类被随意创建,只有内部自己可以创建
private SingletonSynchronized(){ }
// TODO 首先instance对象必须是private并且static,如果不是private那么instance的安全无法得到保证
//TODO 其次因为工厂方法必须是static,因此变量instance也必须是static
private static SingletonSynchronized instance = new SingletonSynchronized();
public static SingletonSynchronized getInstance() {
return instance;
}
}
注意:上述4种方式实现单例模式的缺点
1)反序列化对象时会破环单例
反序列化对象时不会调用getXX()方法,于是绕过了确保单例的逻辑,直接生成了一个新的对象,破环了单例。
解决办法是:重写类的反序列化方法,在反序列化方法中返回单例而不是创建一个新的对象。
//反序列时直接返回当前INSTANCE
private Object readResolve() {
return INSTANCE;
}
2)在代码中通过反射机制,直接调用类的私有构造函数创建新的对象,会破环单例
解决办法是:维护一个volatile的标志变量在第一次创建实例时置为false;重写构造函数,根据标志变量决定是否允许创建。
private static volatile boolean flag = true;
private Singleton(){
if(flag){
flag = false; //第一次创建时,改变标志
}else{
throw new RuntimeException(“The instance already exists !”);
}
原理:定义枚举类型,里面只维护一个实例,以此保证单例。每次取用时都从枚举中取,而不会取到其他实例。
public enum SingletonEnum {
INSTANCE;
private String name;
public String getName(){
return name;
}
public void setName(String name){
this.name = name;
}
}
优点:
1)使用SingletonEnum.INSTANCE进行访问,无需再定义getInstance方法和调用该方法。
2)JVM对枚举实例的唯一性,避免了上面提到的反序列化和反射机制破环单例的情况出现:每一个枚举类型和定义的枚举变量在JVM中都是唯一的。
原因:枚举类型在序列化时仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。
同时,编译器禁止重写枚举类型的writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。
在业务允许的情况下: