java并发编程中的一些基础概念

博主在学习并发编程的过程当中,使用的书籍是《Java并发编程实战》这本书,但阅读下来,只能说本书的内容是很适合学习的,但是不知道是因为原版英语图书本身的写作问题,还是译者的翻译问题,本书的第一部分——基础知识 阅读起来难以理解。书中使用了大量的并发编程领域的专业词汇,由于本书不是很好阅读,这是博主半年后第二次尝试阅读该书,终于理解了书中内容,尤其是第一部分。为了方便后续的学习,在这里先把第一部分的内容梳理一下,对几个重要的关键词汇做一些解释。

线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

不变性

书中在介绍不变性时讲的是不可变对象,但是在书中很多地方提到的却是不变性条件,其实不变性和不可变对象是两回事。所谓的不可变性条件,是指在程序执行过程或部分过程中,可始终被假定成立的条件。而不可变对象则是一种实例对象。

不可变对象

要使一个对象成为不可变对象,需要满足以下三个条件:

  • 对象创建后其状态就不能修改
  • 对象的所有与都是final类型的
  • 对象被正确地创建了(在对象的创建期间,this引用没有逸出)。

事实不可变对象

如果对象从技术上来看是可变的,但其状态在对象被创建后就不会再变化,那么把这种对象成为“事实不可变对象”。事实不可变对象不需要满足不可变对喜爱那个的前两个条件。

原子性

一段代码或者一句代码包含多个操作,这些操作要么全部执行,要么全都不执行,称为原子性。

竞态条件

在并发编程中,由于操作不具备原子性,因此由于不恰当的执行时序而出现不正确的结果。最常见的竞态条件:

  1. 先检测后执行
    竞态条件
    对于main线程,如果文件a不存在,则创建文件a,但是在判断文件a不存在之后,Task线程创建了文件a,这时候先前的判断结果已经失效,(main线程的执行依赖了一个错误的判断结果)此时文件a已经存在了,但是main线程还是会继续创建文件a,导致Task线程创建的文件a被覆盖、文件中的内容丢失等等问题。

  2. 延迟初始化(典型即为单例)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    public class ObjFactory {  
    private Obj instance;

    public Obj getInstance(){
    if(instance == null){
    instance = new Obj();
    }
    return instance;
    }
    }

    单例模式
    线程a和线程b同时执行getInstance(),线程a看到instance为空,创建了一个新的Obj对象,此时线程b也需要判断instance是否为空,此时的instance是否为空取决于不可预测的时序:包括线程a创建Obj对象需要多长时间以及线程的调度方式,如果b检测时,instance为空,那么b也会创建一个instance对象。因此,使用单例模式必须在判断instance == null之前加锁。顺带一提,之所以在加锁前需要再判断一次instance == null,是为了防止在instance已经创建的情况下,线程无谓地获取锁导致的开销。该单例的创建方式称为双重检查锁定

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public class ObjFactory {  
    private Obj instance;

    public Obj getInstance(){
    if(instance == null){
    synchronous(ObjFactory.class) {
    if(instance == null) {
    instance = new Obj();
    }
    }
    }
    return instance;
    }
    }

可见性

所谓的可见性,我们可以简单的理解为线程能够看到某一个变量的最新值。如果一个变量是不可见的,则线程可能会获取到一个失效值,导致程序出项意想不到的错误。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class NoVisibility {
private static boolean ready;
private static int number;
}

private static class ReaderThread extends Thread {
public void run() {
while(!ready) {
Thread.yield();
}
System.out.printLn(number);
}
}

public static class main(String[] args) {
new ReaderThread().start;
number = 42;
ready = true;
}

NoVosobility可能会持续循环下去,因为读线程可能永远都看不到ready的值。一种更奇怪的现象是,NoVisibility可能会输出0,因为读线程可能看到了写入ready的值,但却没有看到之后写入的number的值,产生该问题的原因的Java编译器、处理器以及运行时可能的对操作的执行顺序重排序导致的。
为了使其他线程看到ready的最新值,需要将ready变量用volatile关键字进行修饰。关于volatile关键字的底层原理机制,将在后面一篇文章做专门的介绍。

发布和逸出

“发布”一个对象的意思是指,是对象能够在当前作用域之外的代码中使用。反之,当某个不应该发布的对象被发布时,就称为“逸出”
发布一个对象的安全方式:

  1. 在静态初始化函数中初始化一个对象的引用
  2. 将对象的引用保存到volatile类型的域中或AtomicReference对象中。
  3. 将对象的引用保存到某个正确构造的对喜爱那个的final类型域中
  4. 将对象的引用保存到一个由锁保护的域中

而对象的发布方式,则取决于它的可见性:

  1. 不可变对象可通过任意方式来发布。
  2. 事实不可变对象必须通过安全方式来发布。
  3. 可变对象必须通过安全方式来发布,并且必须是线程安全的或者有某个锁来保护。

两种逃逸的情况:

1
2
3
4
5
6
7
8
9
10
11
public class ThisEscape {
public ThisEscape(EventSource source) {
source.registerListener(
new EventListener {
public void onEvent(Event e) {
doSomething(e);
}
}
)
}
}

该例子当中,在ThisEscape的构造函数还没有退出时,this引用隐式地逸出到了匿名内部类中。

第二种逸出方式是,在构造函数中调用了一个可改写的实例方法(既不是私有方法,也不是终结方法)。更具体地说,就是在创建子类的过程当中,首先会调用父类的构造方法进行父类数据的构造,如果在父类的构造方法中调用了被子类重载的方法,相当于还没有构造完全的父类引用,逃逸到了子类的重构方法代码中了。

关于并发编程中的一些术语就先介绍到这里,上述的几个术语基本上就是并发编程中的重点概念,在下一节当中,将讨论如何实现线程安全类。

欢迎关注个人公众号:
个人公号