多线程的代价
- 设计更复杂:
- 上下文切换的开销:
- 上下文切换(context switch):当CPU从执行一个线程切换到另一个线程的时候,它需要首先存储当前线程的本地数据,程序指针等,然后载入另一个线程的本地数据,程序指针等。最后才开始执行
- 增加资源消耗:
启动线程的顺序可以是有序的,但执行的顺序并非是有序的。这是因为线程是并行执行而非顺序的。JVM和操作系统一起决定了线程的执行顺序,它和线程的启动顺序并非一定是一致的。
竟态条件与临界区
当两个线程竞争同一资源时,如果对资源的访问顺序敏感,就称存在竟态条件。导致竟态条件发生的代码区称作临界区。在临界区使用适当的同步就可以避免竟态条件
线程安全与共享资源
局部变量
局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以基础类型的局部变量是线程安全的
局部的对象引用
对象的局部引用和基础类型的局部引用不太一样。尽管引用本身没有被线程共享,但引用所指的对象并没有存储在线程的栈内。所有对象都存在共享堆中。如果在某个方法中创建的对象不会逃逸出(即该对象不会被其他方法获得,也不会被非局部变量引用到)该方法,那么他就是线程安全的
样例中LocalObject对象没有被方法返回,也没有被传递给someMethod()方法外的对象。每个执行someMethod()的线程都会创建自己的LocalObject对象,并赋值给localObject引用。因此,这里的LocalObject是线程安全的。事实上,整个someMethod()都是线程安全的。即使将LocalObject作为参数传给同一个类的其它方法或其它类的方法时,它仍然是线程安全的。当然,如果LocalObject通过某些方法被传给了别的线程,那它就不再是线程安全的了。
对象成员
对象成员存储在堆上。如果两个线程同时更新同一个对象的同一个成员,那这个代码就不是线程安全的。
如果两个线程同时同时调用一个NotThreadSafe实例上的add()方法,就会有竟态条件问题。例如:
注意两个MyRunnable共享了一个NotThreadSafe对象。因此,当他们调用add()方法时就会造成竟态条件。
现在两个线程都有自己单独的NotThreadSafe对象,调用add()方法时就会互不干扰,再也不会有竞态条件问题了。所以非线程安全的对象仍可以通过某种方式来消除竞态条件。
线程控制逃逸规则
如果一个资源的创建,使用,销毁都在同一个线程内完成,且永远不会脱离该线程的控制,则该资源的使用就是线程安全的。
资源可以是对象,数组,文件,数据库连接,套接字等等。
线程安全及不可变性
当多个线程访问同一个资源,并且其中的一个或多个线程对这个资源进行了写操作,才会产生竟态条件。多个线程同时读同一个资源不会产生竟态条件。
我们可以通过创建不可变的共享对象来保证对象在线程间不会被修改,从而实现线程安全。如下:
请注意ImmutableValue类的成员变量value是通过构造函数赋值的,并且在类中没有set方法。这意味着一旦ImmutableValue实例被创建,value变量就不能再被修改,这就是不可变性。但你可以通过getValue()方法读取这个变量的值。
如果你需要对ImmutableValue类的实例进行操作,可以通过得到value变量后创建一个新的实例来实现,下面是一个对value变量进行加法操作的示例:
引用不是线程安全的!
即使一个对象是线程安全的不可变对象,指向这个对象的引用也可能不是线程安全的。如下:
Calculator类持有一个指向ImmutableValue实例的引用。注意,通过setValue()方法和add()方法可能会改变这个引用。因此,即使Calculator类内部使用了一个不可变对象,但Calculator类本身还是可变的,因此Calculator类不是线程安全的。换句话说:ImmutableValue类是线程安全的,但使用它的类不是。当尝试通过不可变性去获得线程安全时,这点是需要牢记的。
要使Calculator类实现线程安全,将getValue()、setValue()和add()方法都声明为同步方法即可。
Java内存模型内部原理
每一个运行在Java虚拟机里的线程都有自己的线程栈。这个线程栈包含了这个线程调用的方法当前执行点相关的信息。一个线程仅能访问自己的线程栈。一个线程创建的本地变量对其他线程不可见,仅自己可见。即使两个线程执行同样的代码,这两个线程任然在在自己的线程栈中的代码来创建本地变量。因此,每个线程拥有每个本地变量的独有版本。
所有原始类型的本地变量都存放在线程栈上,因此对其它线程不可见。一个线程可能向另一个线程传递一个原始类型变量的拷贝,但是他不能共享这个原始类型变量自身。
Java内存模型和硬件内存架构之间的桥接
对于硬件,所有线程栈和堆都分布在主内存中,部分线程栈和堆可能有时候会出现在CPU缓存中和CPU内部的缓存器中。
想象一下,共享对象被初始化在主存中。跑在CPU上的一个线程将这个共享对象读到CPU缓存中。然后修改了这个对象。只要CPU缓存没有被刷新会主存,对象修改后的版本对跑在其它CPU上的线程都是不可见的。这种方式可能导致每个线程拥有这个共享对象的私有拷贝,每个拷贝停留在不同的CPU缓存中。
下图示意了这种情形。跑在左边CPU的线程拷贝这个共享对象到它的CPU缓存中,然后将count变量的值修改为2。这个修改对跑在右边CPU上的其它线程是不可见的,因为修改后的count的值还没有被刷新回主存中去。
volatile关键字可以保证直接从主存中读取一个变量,如果这个变量被修改后,总是会被写回到主存中去。
race conditions
一个同步块可以保证在同一个时刻仅有一个线程可以进入代码的临界区。同步块还可以保证代码中所有访问变量将会从主存中读入,当线程退出同步代码块时,所有被更新的变量都会被刷新回主存中去,不管这个变量是否被声明为volatile。
Java同步块
Java同步块分为以下四种类型
- 实例方法同步
- 实例方法同步是同步在拥有该方法的对象上。一个实例一个线程
- 静态方法同步
- 静态方法同步是指同步在该方法的所有类对象上。因为在Java虚拟机中一个类只能对应一个类对象,所以同时只允许一个线程执行同一个类中的静态方法。
- 实例方法中的同步块
- 静态方法中的同步块
实例方法中的同步块
|
|
注意Java同步块构造器用括号将对象括起来。在上例中,使用了“this”,即为调用add方法的实例本身。在同步构造器中用括号括起来的对象叫做监视器对象。上述代码使用监视器对象同步,同步实例方法使用调用方法本身的实例作为监视器对象。
一次只有一个线程能够在同步于同一个监视器对象的Java方法内执行。
下面两个例子都同步他们所调用的实例对象上,因此他们在同步的执行效果上是等效的。
|
|
在上例中,每次只有一个线程能够在两个同步块中任意一个方法内执行。
如果第二个同步块不是同步在this实例对象上,那么两个方法可以被线程同时执行。
静态方法中的同步块
|
|
这两个方法不允许同时被线程访问。
如果第二个同步块不是同步在MyClass.class这个对象上。那么这两个方法可以同时被线程访问。
线程通信
wait(),notify()和notifyAll()
Java有一个内建的等待机制来允许线程在等待信号的是否变为非运行状态
。java.lang.Object类定义了三个方法,wait()、notify和notifyAll()来实现这个等待机制。
一个线程一旦调用了任意对象的wait()方法,就会变为非运行状态,直到另一个线程调用了同一个对象的notify()方法。为了调用wait()或者notify(),线程必须先获得那个对象的锁。
等待线程将调用doWait(),而唤醒线程将调用doNotify()。当一个线程调用一个对象的notify()方法,正在等待该对象的所有线程中将有一个线程被唤醒并允许执行(校注:这个将被唤醒的线程是随机的,不可以指定唤醒哪个线程)。同时也提供了一个notifyAll()方法来唤醒正在等待一个给定对象的所有线程。
如你所见,不管是等待线程还是唤醒线程都在同步块里调用wait()和notify()。这是强制性的!一个线程如果没有持有对象锁,将不能调用wait(),notify()或者notifyAll()。否则,会抛出IllegalMonitorStateException异常。
死锁
死锁是两个或更多线程阻塞着等待其他处于死锁状态的线程所持有的锁。死锁通常发生在多个线程同时但以不同的顺序请求同一组锁的时候。
避免死锁
从下面几个途径来进行:
- 加锁顺序
- 加锁时限
- 死锁检测
饥饿和公平
如果一个线程因为CPU时间全部被其他线程抢走而得不到CPU运行时间,这种状态称为“饥饿”。解决饥饿的方案被称之为“公平性”-即所有线程均能公平地获得运行机会。
在Java中,以下三个常见的原因会导致线程饥饿:
- 高优先级线程吞噬所有低优先级线程的CPU时间
- 线程被永久堵塞在一个等待进入同步块的状态,因为其他线程总是能在它之前持续地对该同步块进行访问。
- 线程在等待一个本身(在其上调用wait)也处于永久等待的对象,因为其他线程总是被持续地获得唤醒。
- 如果多个线程处在wait()方法执行上,而对其调用notify()不会保证哪一个线程会获得唤醒,任何线程都有可能处于继续等待的状态。因此存在这样一个风险:一个等待线程从来得不到唤醒,因为其他等待线程总是能被获得唤醒。
在Java中实现公平性
|
|
如果有一个以上的线程调用doSynchronized()方法,在第一个获得访问的线程未完成前,其他线程将一直处于阻塞状态,而且在这种多线程被阻塞的场景下,接下来将是哪个线程获得访问是没有保障的。
使用锁方式代替同步块
注意到上面对Lock的实现,如果存在多线程并发访问lock(),这些线程将阻塞在对lock()方法的访问上。另外,如果锁已经锁上(校对注:这里指的是isLocked等于true时),这些线程将阻塞在while(isLocked)循环的wait()调用里面。要记住的是,当线程正在等待进入lock() 时,可以调用wait()释放其锁实例对应的同步锁,使得其他多个线程可以进入lock()方法,并调用wait()方法。
|
|
FairLock新创建了一个QueueObject的实例,并对每个调用lock()的线程进行入队列。调用unlock()的线程将从队列头部获取QueueObject,并对其调用doNotify(),以唤醒在该对象上等待的线程。通过这种方式,在同一时间仅有一个等待线程获得唤醒,而不是所有的等待线程。这也是实现FairLock公平性的核心所在。
Java中的读/写锁
读写锁的实现
读取:没有线程正在做写操作且没有线程请求写操作
写入:没有线程正在做写操作
这里假设写操作的优先级比读操作高
写锁重入
当一个线程已经拥有写锁,才允许写锁重入
读锁升级到写锁
有时候,我们希望一个拥有读锁的线程,也可以获得写锁。这时候需要这个线程是唯一一个拥有读锁的线程。
可重入的ReadWriteLock的完整实现
|
|
对于实例同步方法,锁是当前实例对象。对于静态同步方法,锁是当前对象的class对象
重入锁死
当一个线程重新获取锁,读写锁或者其他不可重入的同步器时,就可能发生重入锁死。可重入的意思是线程可以重复获得它已经持有的锁
避免重入锁死的方法:
- 编写代码时避免再次获取已经持有的锁
- 使用可重入锁
信号量
Semapore(信号量)是一种线程同步结构,用于在线程间传递信号,以避免出现信号丢失,或者像锁一样用于保护一个关键区域。
下面是一个信号量的简单实现:
|
|
take方法发出一个存放在Semaphore内部的信号,而release方法则等待一个信号,当其接收信号后,标记位signal被清空,然后方法终止。
使用Semaphore来产生信号
下面的例子中,两个线程通过Semaphore发出的信号的通知对方
|
|
其他应用:
可以添加计数功能,添加上限,也可以当锁来使用
阻塞队列
阻塞队列与普通队列的区别在于,当 队列为空时,从队列中取元素的操作将会阻塞;或者当队列是满时,往队列里添加元素的操作将会阻塞。
阻塞队列的原理:
必须注意到,在enqueue和dequeue方法内部,只有队列的大小等于上限(limit)或者下限(0)时,才调用notifyAll方法。如果队列的大小既不等于上限,也不等于下限,任何线程调用enqueue或者dequeue方法时,都不会阻塞,都能够正常的往队列中添加或者移除元素。
线程池
简单线程池的实现:
|
|
线程池的实现由两部分组成,类ThreadPool是线程池的公开接口,而类PoolThread用来实现执行任务的子线程。
为了执行一个任务,方法ThreadPool.execute(Runnable r)
用Runnable的实现作为调用参数,在内部,Runnable对象被放入阻塞队列(Blocking Queue),等待着被子线程取出队列。
一个空闲的 PoolThread
线程会把 Runnable
对象从队列中取出并执行。你可以在 PoolThread.run()
方法里看到这些代码。执行完毕后,PoolThread
进入循环并且尝试从队列中再取出一个任务,直到线程终止。
进一步有关线程池的知识:http://ifeve.com/java-threadpool/
CAS
|
|
剖析同步器
|
|
- 状态:同步器中的状态是用来确定某个线程是否有访问权限
- 访问条件:访问条件决定调用test-and-set-state方法的线程是否可以对状态进行设置。
- 状态变化:一旦一个线程获得了临界区的访问权限,它得改变同步器的状态,让其他线程阻塞,防止他们进入临界区。
- 通知策略:一旦某个线程改变了同步器的状态,可能需要通知其他等待的线程状态已经变了。