线程同步机制

 

 

一、线程同步机制

从广义上说,Java平台提供的线程同步机制包括锁、volatile关键字、final关键字、static关键字和一些相关的API,如Object.wait( )/.notify( )等

 

1、锁的概述和概念:

a 线程安全问题的产生:

多个线程并发访问共享变量、共享资源;

解决办法:

一个共享变量或者资源只能被一个线程访问,访问结束后其他线程才能访问(用锁)。

 

b、用锁保护状态:

1、对于可能被多个线程同时访问的可变状态变量(比如银行总账户),在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是有这个锁保护的。

2、每个共享的和可变的变量都应该只有一个锁来保护,从而使维护人员知道是哪一个锁。

3、对于每个包含多个变量的不变性条件,期中涉及的所以变量都需要由同一个锁来保护。

 

  • 许多线程安全类使用的加锁模式是,将可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码进行同步
  • 每个对象都有一个内置锁,只是为了免去显式地创建锁对象

 

c、锁的几个概念

锁的争用:锁可以被看做多线程程序访问共享数据时所持有的一种排他性资源

锁的调度:包括公平调度策略和非公平调度策略。内部锁属于非公平锁;显示锁则两者都支持。

锁的粒度:一个锁保护的共享数据的大小称粒度。锁的粒度过粗会导致线程在申请锁的时候需要进行不必要的等待,影响性能。

锁的开销:锁的开销主要包括锁的申请和释放产生的开销,锁可能导致上下文切换,开销主要是处理器时间。

锁可能导致的问题:

a.锁泄漏:指一个线程获得锁之后,由于程序的错误,致使该锁一直无法被释放而导致其他线程一直无法获得该锁的现象。

b.锁的不正当使用还会导致死锁、锁死等线程活性故障

 

d、锁的作用:

保护共享数据以实现线程安全,包括保障原子性、可见性和有序性(原子性和可见性>>有序性)

原子性:锁通过互斥来保障原子性,互斥是指一个锁一次只能被一个线程所持有,所以,临界区代码只能被一个线程执行,即保障了原子性。

可见性:通过写线程冲刷处理器缓存和读线程刷新处理器缓存实现。获得锁之后,需要刷新处理器缓存,使得前面写线程所做的更新可以同步到本线程。释放锁需要冲刷处理器缓存,使得当前线程对共享数据的改变可以被推送到下一个线程处理器的高速缓冲中。

有序性:写线程在临界区中所执行的一系列操作在读线程所执行的临界区看起来像是完全按照源代码顺序执行的。但是并不能保证不重排,只是重排不会影响。

 

锁在保证线程安全的同时满足三大条件,那么我们必须遵守:

1.线程访问同一组数据的时候必须使用同一个锁

2.线程中任意的一个线程,即使其仅仅是你读取这组共享数据而没有对其进行更新的话,也需要在读取时持有相应的锁。

 

锁其实就是把本来并发(未使用锁)的线程改成串行(使用锁)。

 

e、锁的适用场景

        • check-then-act操作:一个线程读取共享数据并在此基础上决定下个操作是什么。
        • read-modify-write操作:一个线程读取共享数据并在此基础上更新该数据。
        • 多个线程对多个共享数据进行更新:共享数据之间存在关联关系

2、内存屏障和重入:

a、线程同步机制的底层助手:内存屏障

内存屏障:在指令序列中就像一堵墙一样使其两侧的指令无法穿越(即不可以重排序)

内存屏障在锁中的使用:

        1. 获取锁
        2. 加载屏障
        3. 获取屏障
        4. 临界区
        5. 释放屏障
        6. 存储屏障

(其中:3和5用来禁止指令重排序)

Java线程同步机制就是使用内存屏障在具体实现的

 

b 重入:

可重入是指对于同一个线程,它可以重新获得已有它占用的锁。 

        • 如果没有重入,当某个线程请求一个由其他线程持有的锁时,发送的请求的线程就会阻塞。
        • 由于内置锁可以重入,因此如果某个线程试图获得一个已经由它持有的锁,那么请求就会成功。

可重入性:一个线程在持有一个锁的时候可以再次申请该锁

如何实现可重入性?可重入锁可以被理解为一个对象,该对象包含一个计数器属性,获取锁计数器+1,释放锁计数器-1;

 

//(比如下面方法,一个同步方法调用另一个同步方法,没有重入的话,等待当前同步结束才能调用新的同步则发生阻塞,重入则避免了这种情况) public class Test { public synchronized f() {} public synchronized g() { // 可以重入,所以当前已经获得锁的线程可以获得f的锁,否则死锁 f(); } }

 

3、Java虚拟机对锁的实现划分:

内部锁:通过synchronized关键字实现 独占锁,在高并发访问情况下,可能会引起上下文切换和线程调度

显示锁:通过java.util.concurrent.locks.Lock接口的实现类实现

 

 

二、发布和溢出

我们知道线程安全问题的产生前提是多少线程共享变量,即使是private变量也可能多被个线程共享。

public class Example{ private Map reg = new HashMap(); public void do(){ //reg操作 } }

如上述代码,多线程多do进行操作时,priavte变量相当于被多个线程共享,就这是发布。当然可以用volatile来保证安全。

 

1、发布:

  是对象能够在当前作用域之外的代码中使用。可以是以下几种情况:

  1. 一,将对象的引用保存在公有变量或公有静态变量中

public class Test { public static List list; public Test(){ list = new ArrayList(); } }

通过list对象可以轻易的遍历list对象中保存的People对象,实际上list对象中保存的People对象也被间接的发布了

 

二,在一个非私有的方法中返回该引用

public class Test { private String[] strs = {"AB","BA"}; public String[] getStrs() { return strs; } }

通过getStrs()方法可以获得本来应该被封装在Test类内部的strs数组

 

三,将对象引用传递给外部方法

外部方法:对当前类来说,外部方法是指行为不完全由当前类来规定的方法,包括其他类中定义的方法以及当前类中可以被改写的方法(既不是私有方法,也不是final方法)

当把一个对象传递给外部方法时,就相当于发布了这个对象

public class Test { public void get(Object obj){ //obj对象逸出 ... } }

 

2、逸出:

某个不应该发布的对象被公布的时候。某个对象逸出后会导致对象的内部状态被暴露,可能危及到封装性,使程序难以维持稳定;若发布尚未构造完成的对象,可能危及线程安全问题。 你必须假设有某个类或线程可能会误用该对象,所以要封装。

  • 不要在构造过程中使this引用逸出。
  • 常见错误:在构造函数中启动一个线程。

 

最常见的逸出是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; } }

 

 

 

3、对象的初始化安全:重访final和static:

安全的发布

(1)      使用synchronized

public classSafeLazyInitialization { private static Resource resource; public synchronized static ResourcegetInstance() { if(resource == null) { resource = new Resource(); } return resource; } }

(2)      使用volatile

class Foo { private static volatile Helper helper = null;//使用volatile修饰使resource的读操作与写操作具有happen-before规则 public static Helper getHelper() { if (helper == null) synchronized(Foo.class) { if (helper == null) helper = new Helper(); } return helper; } // other functions and members... }

 

(3)      静态初始化器(利用JVM锁机制提前初始化)

在初始器中采用了特殊的方式来处理静态域,并提供了额外的线程安全性保证。静态初始化器是由JVM在类的初始化阶段执行即在类被加载后并且在被线程使用之前,由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写入操作将自动对所有线程可见。因此无论是在构造期间还是被引用时,静态初始化的对象都不需要显式的同步。然而,这个规则只适用于构造时的状态,也就是说保证构造的完整性,并不保证之后的操作的线程安全。如果这个类不是线程安全的,仍需要适当的同步。

public classEagerInitialization{ private static Resource resource = new Resource(); public synchronized static ResourcegetResource() { return resource; } }

 

(4)      延迟初始化占位

public classResourceFactory{ private static class ResourceHolder{ private static Resource resource = new Resource(); } public synchronized static ResourcegetResource() { return ResourceHolder.resource; } }

 

(5) Final

public class SafeState{ private final Map states; public SafeState(){ states = new HashMap();//final域写入操作 state.put("a","a");//final域可达的变量,仍然具备可见性,并且不会被重排序到构造函数之后 } }

 

根据上面所讨论的,可以总结出以下安全发布的常用模式:

注意, 我们这里的目的是:在对象没有完成构造之前,不可以将其发布,不安全,不推荐(要安全 = 不能随便new一个对象出来)

不正确的发布可变对象,导致两种错误。

  • 发布线程以外的任何线程都可以看到被发布对象的过期的值,
  • 线程看到的被发布对象的引用是最新的,然而被发布对象的状态是过期的,如果一个对象是可变对象,那么它要被安全发布才可以。

 

 

 

 

三、线程封闭

  当访问共享的可变数据时,通常需要使用同步。一种避免同步的方式就是不共享数据。

  仅在单线程内访问数据,就不需要同步,这种技术被称为线程封闭(Thread Confinement)。

  典型应用:

①Swing的可视化组件和数据模型对象都不是线程安全的,Swing通过将它们封闭到Swing的实际分发线程中来实现线程安全;

②JDBC的Connection对象。

 

线程封闭技术:

①Ad-hoc线程封闭:维护线程封闭性的职责完全由程序实现来承担。

  在volatile变量上存在一个特殊的线程封闭:能确保只有单个线程对共享的volatile变量执行写入操作(其他线程有读取volatile变量),那么就可以安全地在这些共享的volatile变量上执行“读取-修改-写入”的操作,而其他读取volatile变量的线程也能看到最新的值。

 

②栈封闭:在栈封闭中,只能通过局部变量才能访问对象。

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的逸出。

  

③ThreadLocal类:这是线程封闭的更规范方法,这个类能使线程中的某个值与保存值的对象关联起来。

  提供get()和set()方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此get总是返回由当前执行线程在调用set时设置的最新值。

  ThreadLocal对象通常用于防止对可变的单实例变量(Singleton)或全局变量进行共享。怎么理解呢?还是JDBC的Connection对象,防止共享,所以通常将JDBC的连接保存到ThreadLocal对象中,每个线程都会拥有属于自己的连接。

 

你可能感兴趣的:(Java)