白话Java锁--线程安全--无同步实现方案

此篇文章主要是为了介绍除了互斥同步、阻塞同步保证线程安全之外,还有无同步的方式实现线程安全,并且介绍一些相关的基础理论知识,方便我们后面对各种锁的理解。

线程安全

首先介绍一下,什么是线程安全。其实就是多个线程访问一个对象的时候,如果不考虑这些线程运行时环境的调度和交替运行,也不需要进行额外的同步,或者调用方法的任何其他协调操作,调用这个对象都可以获得正确的结果,那么这个对象就是线程安全的。

对象发布

使对象能够在当前作用域之外的代码中所使用。

例如:通过类的非私有方法返回对象的引用

public List list = new ArrayList();
	
public List getList() {
	return this.list;
}

例如:通过公有的静态变量发布对象

public static List list = new ArrayList();

通过上面两种方式发布对象后,对象就能够在当前作用域之外的代码中使用,其他类中的代码就能够共享这个对象,也就是共享资源,此时如果多线程情况下就会有并发问题,这种情况下就叫做不安全的对象发布,就需要同步。

不安全的对象发布–概述

例如:

private String[] states = new String[] { "AK", "AL" };
 
public String[] getStates() {
    return states;
}

如果按照上述方法进行发布,就会有问题,因为任何调用者都可以改变这个数组的内容。states本应该是私有的变量,但是却被发布了,如果是多线程修改或者访问的话就会存在并发问题,即使可能没有并发问题,但是同样存在误用的风险,某个线程可能会误用这个变量。

因为存在这种不安全的对象发布的情况,所以需要封装这种机制,将这个变量进行包裹(封装),封装后进行并发控制,让程序猿能够正确定位并发的变量,减少无意中破坏共享变量的行为,否则如果变量都敞开了,谁都没法控制变量。这也是封装存在的意义之一。

例如:构造方法内隐含引用

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
    
    void doSomething(Event e) {
		do...
	}

}

当 ThisEscape 在实例化本身的时候,同时创建了EventListener对象,而EventListener中调用doSomething()方法的时候,会隐式的创建ThisEscape对象,也就是实际上执行的是this.doSomething()语句。

不安全的对象发布–对象逸出

当某个不应该发布的对象被发布时,这种情况就是逸出。

例如:发布某个对象的时候可能会间接的发布某个其他对象,比如List list=new ArrayList(),在发布list时,同样会把Person对象发布了。

例如:

private final String[] states = new String[] { "AK", "AL" };
 
public String[] getStates() {
    return states;
}

任何调用getStates()方法的代码都可以修改这个数组的内容,数组states已经逸出了他所在的作用域。

例如:隐式的this引用逸出

public class ThisEscape {
  public ThisEscape(EventSource source) {
    source.registerListener(new EventListener() {
      public void onEvent(Event e) {
        doSomething(e);
      }
    });
  }

  void doSomething(Event e) {
  }

  interface EventSource {
    void registerListener(EventListener e);
  }

  interface EventListener {
    void onEvent(Event e);
  }

  interface Event {
  }
}

当实例化ThisEscape对象时,会调用source的registerListener方法,这时便启动了一个线程,而且这个线程持有了ThisEscape对象(调用了this.doSomething方法),但此时ThisEscape对象却没有实例化完成(还没有返回一个引用),此时造成了一个this引用逸出,即还没有完成的实例化ThisEscape对象的动作,却已经暴露了对象的引用。其他线程访问还没有构造好的对象,可能会造成意料不到的问题。

这个意料不到的问题可能是:

public class ThisEscape {

	private int intState;
    private String stringState;

    public ThisEscape(EventSource source) {
      source.registerListener(new EventListener() {
        public void onEvent(Event e) {
          doSomething(e);
        }
      });

		//执行到这里时,new 的EventListener就已经把ThisEscape对象隐式发布了,而ThisEscape对象尚未初始化完成
        
        intState=10;//ThisEscape对象继续初始化....
        stringState = "hello";//ThisEscape对象继续初始化....
        
        //执行到这里时, ThisEscape对象才算初始化完成...

    }

    void doSomething(Event e) {
    }

    interface EventSource {
      void registerListener(EventListe  ner e);
    }

    interface EventListener {
      void onEvent(Event e);
    }

    interface Event {
    }
}

主要关注一下构造方法,EventListener通过this隐式的持有ThisEscape对象,但是此时的ThisEscape对象可能还没有初始化完成,这时的ThisEscape对象是一个尚未构造完成的对象,访问到的成员变量可能是默认值,就会导致程序出错。

不安全的对象发布–不安全的延迟初始化

例如:

public class Resource {
    private int x;
    private String y;

    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource(10,"hello");//不安全的发布
        }
        return resource
    }
}

上面这种不安全的发布主要是由于执行过程中发生的指令重排造成的。(如果对指令重排不理解的可以看我的文章白话内存屏障(Memory Barrier)与volatile关键字),就是编译器为了提高执行效率并行的策略。

那么指令重排发生在哪里呢?主要是在resource = new Resource(10,“hello”);这句话上,实际上这句代码大概分为三个步骤:

  • 为Resource分配内存空间
  • 将resource指向分配的内存空间
  • 调用构造函数初始化对象

这三个步骤不是原子的,如果执行到第二步,还没有进行初始化或者初始化还没有完成,但是此时对象的指针已经不是null了,其他线程再进行判断的时候发现不为空,就会拿着这个没有初始化完成的对象进行操作,那么这个其他线程就会收获一个错误的结果。如图:
在这里插入图片描述

安全的发布

既然存在不安全的对象发布,那么如何保证对象安全的发布呢?

如何解决问题,首先要找到出现问题的原因,出现这种不安全的原因就在于,多线程情况下,线程拿到的对象可能是其他线程没有初始完成的对象,所以解决也很简单让线程拿到其他线程初始化完成的对象就可以了。此时使用final关键字就可以了,final关键字的作用就是一旦对象的引用对其他线程可见了,那么其final成员必须正确的赋值了,所以通过final,就如同对对象的创建或访问加锁了一般,天然保证了对象的安全发布。

public class Resource {
    private final int x;
    private final String y;
    public Resource(){x=10;y="hello"}
    public Resource(int x, String y) {
        this.x = x;
        this.y = y;
    }
}

public class UnsafeLazyInitialization {
    private static Resource resource;

    public static Resource getResource() {
        if (resource == null) {
            resource = new Resource();//安全的发布!
        }
        return resource;
    }
}

在这里插入图片描述

安全的发布–final底层原理

知道了final能够保证对象的安全发布,那么底层是如何处理的呢?

对于指令重排序遵循以下规则:

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

(先写入final变量,后调用该对象引用)

原因:编译器会在final域的写之后,插入一个StoreStore屏障

  1. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

(先读对象的引用,后读final变量)

编译器会在读final域操作的前面插入一个LoadLoad屏障

通过第一条规则,可以知道对象的引用赋值操作完成在final写之后,所以在多线程的情况下,对于final的读取一定是已经写完的,通过final保证了线程的安全。

同理,对于成员为final的读取一定是在对象引用的读取完之后才能读取。

有点类似于门,在装修的时候(写入),需要先把房间先装修好,才能关门(赋给引用变量)。在进入房间的时候(读取)需要先开门(读取引用),再进入房间(读取final内容)。

final 域是引用类型

如果成员变量是final类型的引用变量,对于引用的成员域的写入与指令重排没有任何关系。

安全发布举例–单例设计模式

单例模式分为懒汉式和饿汉式,那么饿汉式如何在多线程情况下保证单例对象的安全发布呢?

public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static synchronized Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
        return instance;
    }
}

为了避开过多的同步操作,使用DCL(Double Check Lock)双重检查

public class Singleton {
    private static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    return instance;
    }
}

但是这用DCL同样存在问题,存在的问题就是在多线程情况下,如果Singleton存在成员变量,多个线程下,可能会读取到对象的默认值,就是对象的不安全发布引起的。

所以对于DCL的修正就是将对象里面的每个元素都声明为final的。也可以将对象的引用声明为volatile的,因为对于volatile对象的读取和修改一定是最新的,是不允许指令重排的,可以保证读取到的值是最新值。

所以最终版:

public class Singleton {
    private volatile static Singleton instance = null;
    private Singleton() {}
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
    return instance;
    }
}

关于懒汉模式,简单的说一下

private static Singleton instance = new Singleton();

因为是在类装载时进行创建的,所以是线程安全的不会有对象不安全发布的情况。但是同样存在如果没有实际调用,会造成资源浪费的问题

顺带说一下,最安全的模式是枚举方式,其实和懒汉模式差不多

线程安全实现方法

保证线程安全的方法有三种:

  • 互斥同步
  • 非阻塞同步
  • 无同步方案

之前的文章已经了解了互斥同步方案和非阻塞同步(CAS),这个主要聊的是无同步方案。

无同步方案又有几种实现方案:

  • 对象不共享
  • 线程本地变量
  • 不可变对象

对象不共享和不可变对象实质上是从根本上就不使用可以共享的变量,不共享不可变就不会产生安全问题了(只有死人才不会说话)

对象不共享好理解,就是不使用共享资源。

不可变对象就是一点被创建,对象的属性值就不可以改变,任何对他的改变都会生成一个新的对象。

顺便提一下,生成不可变对象的类就叫做不可变类(Immutable Class),例如String、基本类型的包装类、BigInteger、BigDecimal等。

不可变类

  • 不能被继承,类的声明为final,或者使用静态工厂声明构造器为private。如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量的值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。
  • 使用private和final修饰符来修饰类的属性
  • 不提供任何可以修改对象状态的方法(包括set方法和其他任何可以改变状态的方法)

如果成员属性为可变对象属性

  • 不要提供更改可变对象的方法
  • 不要共享对可变对象的引用。因为用户可以在不可变类之外通过修改可变对象的值
  • 有必要的时候,方法返回的是可变对象的副本而不是原对象
private final Date date;

public Date getDate() {
    return new Date(date.getTime());
}

private final int[] myArray;  

public int[] getArray() {  
    return myArray.clone();   
} 

不可变类优点

  • 构造、测试和使用都很简单
  • 产生的不可变对象是线程安全的,在线程之间可以互相共享,不需要特殊机制保证同步,因为对象的值是无法改变的,可以降低并发的错误的可能性。
  • 不可变对象是可以被重复使用的,可以将他们缓存起来重复使用,就像String一样。可以通过静态工厂方法提供类似于valueOf()这样的方法,从缓存中返回一个已经存在的不可变对象,而不是重新创建一个。

不可变类缺点

最大的缺点就是创建对象的开销很大,每一个操作都会产生一个新的对象,制造大量的垃圾,不能被重复利用,用完后就扔,会制造很多垃圾,给垃圾回收带来很大麻烦。

线程本地变量

线程本地变量实际上利用的就是线程封闭技术,那么什么是线程封闭呢?

线程封闭

线程封闭其实就是把对象封装到一个线程里,只有这个线程才能看到这个对象,那么这个对象即使不是线程安全的,也不会出现任何线程安全方面的问题。因为已经从根本上解决了多线程并发的问题,就没有共享变量。

线程封闭应用

在Connection连接数据库的时候,Connection对象在实现的时候并没有对线程安全做太多的处理,jdbc的规范里也没有要求Connection对象必须是线程安全的。

实际在服务器应用程序中,线程从连接池获取了一个Connection对象,使用完再把Connection对象返回给连接池,由于大多数请求都是由单线程采用同步的方式来处理的,并且在Connection对象返回之前,连接池不会将它分配给其他线程,没有和其他线程的竞争。这是将Connection对象封闭在了线程里面,这样我们的Connection对象即使不是线程安全的,但是它通过线程封闭做到了线程安全。

线程封闭的种类

  1. Ad-hoc 线程封闭:

Ad-hoc线程封闭是指,维护线程封闭性的职责完全由程序实现来承担。Ad-hoc线程封闭是非常脆弱的,因为没有任何一种语言特性,例如可见性修饰符或局部变量,能将对象封闭到目标线程上。事实上,对线程封闭对象(例如,GUI应用程序中的可视化组件或数据模型等)的引用通常保存在公有变量中。

说白了就是通过程序代码进行控制,所以不推荐使用。

  1. 堆栈封闭(最多出现 , 全局变量就容易存在并发问题):

堆栈封闭其实就是方法中定义局部变量。不存在并发问题。多个线程访问一个方法的时候,方法中的局部变量都会被拷贝一份到线程的栈中(Java内存模型),所以局部变量是不会被多个线程所共享的。

说白了就是栈帧只保证了压栈的时候所使用的局部变量的安全,但是不能保证全局变量的安全,对于全局变量来说,还是存在线程安全问题。

  1. ThreadLocal(线程本地变量) :

直接联想java的ThreadLocal即可,内部维护了一个map,key是每个线程名称,value就是需要封闭的对象,每个线程在操作的时候都会到map中寻找自己的对象,每个操作都是基于自己线程的,所以他是线程安全的。

总结

写这篇文章主要就是为了了解一下实现线程安全的方案中的无同步方案,但实际上感觉就是从根本上就不创建线程不安全的场景,不可变对象就是不创建共享资源,从而没有线程不安全的场景;线程本地变量就是把共享资源放到每个线程里面去,从而不共享,所以没有线程不安全的场景。

所以实际上也就了解了一下对象的发布和逸出,还有线程封闭的知识。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值