《Java并发编程实战》读书笔记一:基础知识

一、线程安全性

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

1. 无状态对象

无状态对象不包含域,也不包含与其他类中域的引用,计算过程中的临时状态仅存在于线程栈中的局部变量上,并且只能由正在执行的线程访问。访问无状态对象的线程不会影响另一个访问同一个对象的线程的计算结果,因为这两者之间没有共享关系。因此,无状态对象是线程安全的。

public class A{
      public void service(){
      }
}

2. 原子性

原子性从字面意思上看就是,要么全部都做,要么全部都不做。具有原子性的操作是线程安全的,例如 i = 1;不具有原子性的操作不是线程安全的,例如i++。因为i++实际上分为三步,读取i,将值加1,写回i。

3. 竞态条件

当某个计算的正确性取决于多个线程的交替执行顺序时,那么就会发生竞态条件,也就是说,正确的结果取决于运气。竞态条件和原子性相关,或者说,之所以代码会发生竞态条件,就是因为代码不是以原子方式操作的,而是一种复合操作。
例如:

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

这里会存在竞态条件(先检查后执行)。假设线程B1和B2同时执行getInstance,B1看到instance为空,因此他会进入到new A()的操作创建A的实例。B2同时也要判断instance是否为空,此时的instance是否为空,却取决于不可预测的时序(包括线程的调度方式),以及B1要花多少时间来new一个A的实例。如果B1在new操作时,轮到B2线程被调度,那么此时B2判断的instance也为空,因此到最后,会出现两个A的实例。

同理对于i++也一样存在竞态条件(读取—修改—写入)。

解决:
对于这两种竞态条件,我们要避免它,就要保证它是原子方式执行,即在某个线程修改该变量时,通过某种方式防止其他线程使用这个变量。

i++这种情况可以使用concurrent.atomic包实现原子操作,而先检查后执行这种竞态条件则可以通过加锁来实现同步。

4. concurrent.atomic包实现原子操作

原子操作类主要用于高并发环境下的程序处理,这些处理主要有:

  • 基本类:AtomicInteger, AtomicLong,AtomicBoolean
  • 引用类型:AtomicReference
  • 数组类型:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray

像上面提到的递增,就可以用原子类来实现

class B {
    private AtomicInteger count = new AtomicInteger(0);

    public void inc(){
        count.incrementAndGet();
    }
}

二、加锁机制

1.内置锁

Java提供了一种内置的锁机制来支持原子性:同步代码块(Synchronized),同步代码块包括两部分:一个作为锁的对象引用,一个作为由这个锁保护的代码块。
对于前面先检查后执行的竞态条件,可以通过加锁来实现线程安全

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

谈到锁,就要谈到双重加锁机制:

public class A {
    private static A instance = null;
    private byte[] lock = new byte[0];
    public static A getInstance(){
        if(instance == null){
            synchronized (lock){      //1
                if(instance == null){ //2
                   instance = new A();//3
                }
            }
        }
        return instance;
    }
}

双重加锁的理念是这样的:
1. 线程 1 进入 getInstance() 方法。
2. 由于 instance 为 null,线程 1 在 //1 处进入 synchronized 块。
3. 线程 1 被线程 2 预占。
4. 线程 2 进入 getInstance() 方法。
5. 由于 instance 仍旧为 null,线程 2 试图获取 //1 处的锁。然而,由于线程 1 持有该锁,线程 2 在 //1 处阻塞。
6. 线程 2 被线程 1 预占。
7. 线程 1 执行,由于在 //2 处实例仍旧为 null,线程 1 还创建一个 Singleton 对象并将其引用赋值给 instance。
8. 线程 1 退出 synchronized 块并从 getInstance() 方法返回实例。
9. 线程 1 被线程 2 预占。
10. 线程 2 获取 //1 处的锁并检查 instance 是否为 null。
11. 由于 instance 是非 null 的,并没有创建第二个 Singleton 对象,由线程 1 创建的对象被返回。

按理论来说,这是完美的。双重检查锁定的问题是:并不能保证它会在单处理器或多处理器计算机上顺利运行。双重检查锁定失败的问题并不归咎于 JVM 中的实现 bug,而是归咎于 Java 平台内存模型。内存模型允许所谓的“无序写入”,这也是这些习语失败的一个主要原因

所以不要使用双重锁定!

2.内置锁的特性

(1)自动获得和释放:
每个java对象都可以隐式地扮演一个用于同步的锁的角色,这些内置的锁被称为内部锁或监视器锁,执行线程进入synchronized块之前自动获得锁,而无论是正常退出还是抛出异常,线程都会自动释放锁。因此获得内部锁的唯一途径是进入这个内部锁保护的同步块或方法。

(2)互斥性:
内部锁在java中扮演了互斥锁的角色,即至多只有一个线程可以拥有锁,没有获取到锁的线程只能等待或阻塞直到锁被释放,因此同步块可以线程安全地原子执行。

(3)可重入性:
可重入是指对于同一个线程,它可以重新获得已有它占用的锁。
可重入性意味着锁的请求是基于”每线程”而不是基于”每调用”,它是通过为锁关联一个请求计数器和一个占有它的线程来实现。
可重入性方便了锁行为的封装,简化了面向对象并发代码的开发,可以防止类继承引起的死锁,例子如下:

public class Widget {  
    public synchronized void doSomething(){  
        ......  
    }  
}  

public class LoggingWidget extends Widget {  
    public synchronized void doSomething(){  
        System.out.println(toString() + “: calling doSomething”);  
        super.doSomething();  
    }  
}

子类LoggingWidget覆盖了父类Widget中synchronized类型的doSomething方法,并调用了父类的中的同步方法,因此子类LoggingWidget和父类Widget在调用doSomething方法之前都会先获取Widget的锁,若内部锁没有可重入性,则super.doSomething的调用就会因为无法获得锁而被死锁。

三、内存可见性和指令重排序

参考 —> Java并发编程:Java内存模型和volatile关键字解析

四、发布与逸出

发布:发布一个对象的意思是,使对象能够在当前作用域之外的代码中使用。
1. 将一个引用存储到其他代码可以访问的地方;
2. 在一个非私有的方法中返回该引用;
3. 将该对象传递到其他类的方法中等。

public static Set secrets;
public void init(){
     secrets = new HashSet();
}

当发布某个对象时,可能间接地发布其他对象。例如如果将一个Secret对象添加到集合secrets中,那么在发布secrets的同时,也会发布Secret对象,因为任何代码都可以遍历这个集合,并获得对Secret对象的引用。

逸出:当某个不应该发布的对象被发布时,这种情况就是逸出。
对象逸出会导致对象的内部状态被暴露,可能危及到封装性,使程序难以维持稳定;若发布尚未构造完成的对象,可能危及线程安全问题。
最常见的逸出是this引用在构造时逸出,导致this引用逸出的常见错误有:

  1. 在构造函数中启动线程:
    当对象在构造函数中显式还是隐式创建线程时,this引用几乎总是被新线程共享,于是新的线程在所属对象完成构造之前就能看见它。
    避免构造函数中启动线程引起的this引用逸出的方法是不要在构造函数中启动新线程,取而代之的是在其他初始化或启动方法中启动对象拥有的线程。

  2. 在构造方法中调用可覆盖的实例方法:
    在构造方法中调用那些既不是private也不是final的可被子类覆盖的实例方法时,同样导致this引用逸出。
    避免此类错误的方法时千万不要在父类构造方法中调用被子类覆盖的方法。

  3. 在构造方法中创建内部类:
    在构造方法中创建内部类实例时,内部类的实例包含了对封装实例的隐含引用(深入理解 内部类),可能导致隐式this逸出。例子如下:

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

上述例子中的this逸出可以使用工厂方法来避免,例子如下:

public class SafeListener {  
    private final EventListener listener;  

    private SafeListener(){  
        listener = new EventListener(){  
            public void onEvent(Event e){  
                doSomething(e);  
            }  
        );  
    }  

    public static SafeListener newInstance(EventSource source) {  
        SafeListener safe = new SafeListener();  
        source.registerListener(safe.listener);  
        return safe;  
    }  
}

六、线程封闭

当访问共享的可变数据时,通常需要同步。一种避免使用同步的方式就是不同享数据,这叫做线程封闭。java提供了一些机制来维持线程封闭性,例如局部变量和ThreadLocal类。
线程封闭技术的一个常见应用是JDBC的Connection对象。JDBC规范不要求Connection对象时线程安全的,而要求连接池是线程安全的。线程聪哥线程池中获得一个Connection对象,并且用该对象来处理请求,使用完之后再返回给连接池。由于大多数请求(例如Servlet请求和EJB)都是单个线程采用同步的方式来处理,并且在Connection对象返回前,连接池不会再把它分配给其它线程,因此这种连接在处理请求时,把Connection对象封闭在线程中。

1. 栈封闭(局部变量)

栈限制是线程封闭的一种特例,只能通过局部变量才可以访问对象,局部使对象限制在执行线程中,存在于执行线程栈,其他线程无法访问这个栈,从而确保线程安全。(每一个线程都有一个工作内存,工作内存中班包括有栈,局部的基本类型变量是处于栈中,引用类型的引用处于栈中,而引用指向的对象处于堆中)。
栈限制的例子如下:

public int loadTheArk(Collection candidates){  
    SortedSet animals;  
    int numPairs = 0;  
    Animal candidate = null;  

    //animals被限制在本地方法栈中  
    animals = new TreeSet(new SpeciesGenderComparator());  
    animals.addAll(candidates);  
    for(Animal a : animals){  
        if(candidate == null || !candidate.isPotentialMate(a)){  
            candidate = a;  
        }else{  
            ark.load(new AnimalPair(candidate, a));  
            ++numPairs;  
            candidate = null;  
        }  
    }  
    return numPairs;  
} 

指向TreeSet对象的唯一引用保存在animals中,而animals这个引用被封闭在局部变量中,因此封闭在线程本身的工作内存中,其它线程不能访问。如果发布了对集合的引用,那么线程的封闭性将被破坏,并且导致对象animals的逸出。

2. ThreadLocal类

ThreadLocal线程本地变量是一种规范化的维护线程限制的方式,它允许将每个线程与持有数值的对象关联在一起,为每个使用它的线程维护一份单独的拷贝。ThreadLocal提供了set和get访问器,get总是返回由当前线程通过set设置的最新值。

public static ThreadLocal num = new ThreadLocal(){

        @Override
        protected Integer initialValue() {
            return 0;
        }

    };

    public ThreadLocal getThreadLocal(){
        return num;
    }

    public int getNextNum(){
        num.set(num.get() + 1);
        return num.get();
    }

我们来看看ThreadLocal是如何做到对每一个线程都做到独立的副本的。


《Java并发编程实战》读书笔记一:基础知识_第1张图片
20161220165904004.png

在set方法中我们可以看到,ThreadLocalMap这个应该是关键,把ThreadLocalMap看成一个map。ThreadLocalMap是通过getMap(t)方法获得的,传入的t是当前线程,也就是说,ThreadLocalMap是与各自线程绑定的。此后,ThreadLocalMap通过set方法,把当前的ThreadLocal作为key,传入的值作为value保存在ThreadLocalMap中。ThreadLocal通过操作每一个线程特有的ThreadLocalMap对象,从而实现了变量访问在不同线程中的隔离。

3. 不可变对象

如果一个对象在创建后其状态就不能被修改,那么这个对象就称为不可变对象。
不可变对象需要满足下面条件:
1. 对象本身是final的(避免被子类化),声明属性为private 和 final
2. 不可变对象的状态在创建后就不能再改变,不要提供任何可以修改对象状态的方法 - 不仅仅是set方法, 还有任何其它可以改变状态的方法,每次对他们的改变都是产生了新的不可变对象的对象。
3. 不可变对象能被正确地创建(在创建过程中没有发生this引用逸出)。
4. 如果类有任何可变对象属性, 那么当它们在类和类的调用者间传递的时候必须被保护性拷贝

不可变对象一定是线程安全的,不需要任何同步或锁的机制就可以保证安全地在多线程之间共享。

你可能感兴趣的:(《Java并发编程实战》读书笔记一:基础知识)