这次来学习下Java中线程的相关知识,也是之后学习Java并发机制的基础。前面在学习Handler机制以及JVM之内存管理与分配机制时均简要介绍过线程,那么首先来复习下从这两篇文章中我们了解到的线程的相关知识,有利于我们之后的学习。
在Handler机制学习中,我们介绍了进程和线程之间的区别:进程是操作系统资源分配的基本单位,而线程是任务调度和执行的基本单位;进程都有独立的代码和数据空间,线程组之间只能共享资源,每个线程都有自己独立的运行栈和程序计数器(PC)。还介绍了多线程的概念,通过将CPU的时间片按照调度算法轮流分配给各个线程从而实现多任务执行。此外,还简要介绍了线程中的几种状态,例如New、Runnable、Running、Blocked、Dead等,当然这里更多的是学习安卓中的线程使用,不过原理都是相同的。在JVM之内存管理与分配机制学习中,我们了解到每个线程都有自己独立的虚拟机栈(用来保存Java方法的局部变量、方法参数等信息)、程序计数器(记录要执行指令的地址)、本地方法栈(和虚拟机栈相似,只不过这里换成Native方法)。通过上述的学习,我们大概对线程有了基本的认知。除此之外,我们还要学习下线程中比较重要的知识例如多线程之间由于共享和竞争数据所导致的问题以及解决方案等等,主要参考《Java编程的逻辑》。
首先了解下Java并发编程的三大原则:
- 原子性:单个或多个操作是要么全部执行,要么都不执行
- 可见性:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改
- 有序性:如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的
1. 线程基本属性、方法以及创建方式
首先介绍下Java中线程的一些基本属性、方法、Java中的几种创建方式以及多线程的优点。
1)线程的一些基本属性:
属性 | 说明 |
---|---|
id | 线程 id 用于标识不同的线程,编号可能被后续创建的线程使用。编号是只读属性,不能修改 |
name | name的默认值是"Thread-"后跟一个编号,可通过setName方法进行设置 |
daemon | 分为守护线程和用户线程,我们可以通过 setDaemon(true) 把线程设置为守护线程。守护线程通常用于执行不重要的任务,比如监控其他线程的运行情况,GC 线程就是一个守护线程。setDaemon() 要在线程启动前设置,否则 JVM 会抛出非法线程状态异常,可被继承 |
priority | 线程调度器会根据这个值来决定优先运行哪个线程(不保证),优先级的取值范围为 1~10,默认值是 5,可被继承。例如最低优先级:MIN_PRIORITY = 1 |
state | 表示线程的状态,可通过getState方法获取,返回值是一个枚举类型State,State中包括了线程的如下几种状态NEW,RUNNABLE,BLOCKED,WAITING,TIMED_WAITING,TERMINATED |
这里再介绍下线程的几种state:
state | 说明 |
---|---|
NEW | 新创建了一个线程对象,但还没有调用start()方法 |
RUNNABLE | 可以理解为包括了Ready以及Running状态。线程对象创建后,处于Ready状态,其他线程调用了该对象的start()方法。该状态的线程位于可运行线程池中准备运行,等待获取CPU的使用权后就转到运行(Running)状态;运行(Running)状态的线程调用Thread类的yield()方法临时暂停当前执行的线程,以便其他线程有机会执行,此时线程由Running状态转变为Ready状态 |
WAITING | 线程进入等待状态因为以下几个方法:Object类wait()、Thread类sleep()、Thread类join() |
TIMED_WAITING | 有等待时间的等待状态:Object类wait(timeout)、Thread类sleep(timeout)、Thread类join(timeout) |
BLOCKED | 线程因为某种原因放弃CPU使用权,暂时停止运行,直到线程进入就绪状态,才有机会转到运行状态。例如同步阻塞的场景:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放入锁池中 |
TERMINATED | 表示该线程已经执行完毕 |
2)线程的一些常用方法:
Object类的wait()、notify()、 notifyAll()方法,用于控制线程状态,三个方法都必须在synchronized 同步关键字所限定的作用域中调用,否则会报错
方法 | 说明 |
---|---|
wait() | 将当前运行的线程挂起放入等待队列中,线程状态由Running 变为 Waiting,直到notify()或notifyAll()方法来唤醒线程 |
notify() | 将等待队列中一个等待线程从等待队列移动到同步队列中,被移动的线程状态由 Running 变为 Blocked |
notifyAll() | 将所有等待队列中的线程移动到同步队列中,线程notifyAll()唤醒后需要竞争到锁后才能去执行 |
Thread类的join()、sleep()、yield()方法
方法 | 说明 |
---|---|
sleep() | 让当前线程暂停指定的时间(毫秒),与wait()方法不同的是:wait方法依赖于synchronized同步,而sleep方法可以直接调用;sleep() 方法在睡眠时不释放对象锁,只是暂时让出CPU的执行权,而wait方法则需要释放锁 |
join() | 很多情况下,主线程创建并启动子线程,如果子线程中需要进行大量的耗时计算,主线程往往早于子线程结束。这时如果主线程想等待子线程执行结束之后再结束,比如子线程处理一个数据,主线程要取得这个数据,就要用 join() 方法,和wait()方法一样,join() 方法在等待的过程中会释放对象锁 |
yield() | 暂停当前线程,线程由Running状态转变为Runnable状态,以便其他线程有机会执行,不过不能指定暂停的时间,执行了yield方法的线程什么时候会继续运行由线程调度器来决定 |
这里也可以看到sleep()、yield()、join()方法都是Thread类的方法;而wait()、notify()、notifyAll()都是定义在Object类的实例方法,Java中所有的类都继承自Object类,因此相当于所有类都包含这三个方法。这里就有个问题是wait()、notify()、notifyAll()这三个方法为什么位于Object类中,并且还都必须在synchronized 同步关键字所限定的作用域中调用呢?这就要弄清wait方法的实现原理,和synchronized机制有关系,之后我们再来回答这个问题。
3)Java中创建线程的两种常见方式,一种是继承Thread,另外一种是实现Runnable接口:
- 继承Thread重写其run方法
public class HelloThread extends Thread {
@Override
public void run() {
System.out.println("hello");
}
}
public static void main(String[] args) {
Thread thread = new HelloThread();
//调用start方法才能启动一条单独的线程执行流,若仅调用run方法
//那么run方法的代码依然是在main线程中执行的
thread.start();
}
- 实现Runnable接口
public interface Runnable {
public abstract void run();
}
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println("hello");
}
}
public static void main(String[] args) {
Thread helloThread = new Thread(new HelloRunnable());
helloThread.start();
}
4)多线程的优点
- 充分利用多CPU的计算能力。单线程只能利用一个CPU,使用多线程可以利用多CPU的计算能力。
- 充分利用硬件资源。CPU和硬盘、网络是可以同时工作的,一个线程在等待网络IO的同时,另一个线程完全可以利用CPU,对于多个独立的网络请求,完全可以使用多个线程同时请求。
- 在用户界面(GUI)应用程序中,保持程序的响应性,界面和后台任务通常是不同的线程,否则,如果所有事情都是一个线程来执行,当执行一个很慢的任务时,整个界面将停止响应,也无法取消该任务。
2. 线程共享内存及问题
前面我们提到,每个线程都可以表示一条单独的执行流,都有自己的程序计数器、栈等,但线程之间可以共享内存,它们可以访问和操作相同的对象,那么多线程共享和竞争数据必然会导致一些问题。
2.1 共享与竞态
所谓竞态条件是指,当多个线程访问和操作同一个共享对象时,最终执行结果与执行时序有关,可能正确也可能不正确,举例如下:
public class ShareData {
public static int count = 0;
public static void main(String[] args) {
final ShareData data = new ShareData();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
for (int j = 0; j < 100; j++) {
data.addCount();
}
System.out.print(count + " ");
}
}).start();
}
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("count=" + count);
}
public void addCount() {
count++;
}
}
上述代码的目的是对count进行加一操作,执行1000次,这里通过10个线程来实现的,每个线程执行100次,正常情况下,应该输出1000。但是运行上面的程序会发现结果却不是这样,而是不确定的数字。由上述程序可以看出对共享变量操作,在多线程环境下很容易出现各种意想不到的的结果。究其原因,是因为counter++这个操作不是原子操作,它分为了三个步骤而非一个步骤就能完成:
1.取counter的当前值
2.在当前值基础上加1
3.将新值重新赋值给counter
两个线程可能同时执行第一步,取到了相同的counter值,比如都取到了100,第一个线程执行完后counter变为101,而第二个线程执行完后还是101,最终的结果就与期望不符。解决该问题的方法有:
- 使用synchronized关键字
例如将上述程序ShareData类中的addCount方法加上synchronized关键字。简单来说,加上synchronized关键字可以保证资源互斥,也就是同时只允许一个访问者对其进行访问,具有唯一性和排它性。通常情况下我们允许多个线程同时对数据进行读操作,但同一时间内只允许一个线程对数据进行写操作。所以通常将锁分为共享锁和排它锁,也叫做读锁和写锁。对于共享数据的写操作,一般就需要保证互斥性,上面程序就是因为没有保证互斥性才导致数据的修改产生问题。synchronized关键字的原理我们之后学习。
......
//再执行代码会发现无论执行多少次,返回的最终结果都是1000,因为保证了共享数据互斥性
public synchronized void addCount() {
count++;
}
- 使用显式锁
synchronized的局限性在于可能会发生死锁,因此还可以使用显式锁接口Lock,它可以解决synchronized的限制。关于死锁和显式锁我们下面介绍锁时再学习。 - 使用原子变量
这里就要提到原子性的概念,原子性就是指对数据的操作是一个独立的、不可分割的整体。换句话说,就是一次操作,是一个连续不可中断的过程,数据不会执行的一半的时候被其他线程所修改。保证原子性的最简单方式是操作系统指令,就是说如果一次操作对应一条操作系统指令,这样肯定可以能保证原子性。举个例子来说int i=1这个条指令就是个原子操作,它可以使用一条汇编指令完成,但是i++这条指令就分成了(1)读取整数 i 的值;(2)对 i 进行+1操作;(3)将结果写回内存,这种情况下就不再是原子操作了。
这也是最开始代码段执行的结果为什么不正确的原因,对于count++这种组合操作,要保证原子性,最常见的方式是加锁,Java中的Synchronized或Lock都可以实现加锁操作。除了锁以外,还有一种方式就是CAS(比较并替换),即修改数据之前先比较与之前读取到的值是否一致,如果一致则进行修改,如果不一致则重新执行,这种也是乐观锁的实现原理。不过CAS在某些场景下不一定有效,比如另一线程先修改了某个值,然后再改回原来值,这种情况下CAS是无法判断的。
2.2 内存可见性
要理解可见性,需要先对JVM的内存模型有一定的了解,JVM的内存模型与操作系统类似,如下图所示:
由上图可见,每个线程都有一个自己的工作内存(相当于CPU高级缓冲区,其内存储着主内存中共享变量的副本,这么做的目的在于进一步缩小存储系统与CPU之间速度的差异以提高性能),对于共享变量来说,线程每次读取的是工作内存中共享变量的副本,写入的时候也直接修改工作内存中共享变副本的值,然后在某个时间点上再将工作内存与主内存中的值进行同步。这样导致的问题是,如果线程1对某个变量进行了修改,线程2却不一定马上就能看到,甚至永远也看不到线程1对共享变量所做的修改,这就是内存可见性问题。
要解决这种问题,可以使用volatile关键字,当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步,因此在读取 volatile 类型的变量时总会返回最新写入的值。除此之外还可以使用synchronized关键字或显式锁同步,都可解决内存可见性的问题。
3. 线程同步synchronized、volatile、锁&锁优化
3.1 synchronized
多线程共享内存存在两个问题:竞态条件以及内存可见性,解决这两个问题的一个方案是使用synchronized关键字,前面也提到过当它用来修饰一个实例方法时,能使得在同一时刻最多只有一个线程执行该段代码,这样就保证了资源的互斥。这里主要从修饰方法/代码块、特性以及原理三方面阐述synchronized:
3.1.1 synchronized修饰方法/代码块
synchronized除了能用来修饰类的实例方法外,还能用来修饰静态方法(类方法)和代码块。
1)修饰普通的实例方法,示例如下:
public class SynchronizedTest {
public synchronized void method1(){
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public synchronized void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
执行结果如下:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
表面上看加上synchronized修饰符后,使得同时只能有一个线程执行实例方法,但是这个理解是不准确的,synchronized保护的并不是方法,而是同一个对象的方法调用,也就是synchronized保护的是对象而非方法。这个例子中,synchronized保护的就是SynchronizedTest类对象test的方法调用,因为访问的是同一个对象test的synchronized方法,即使是不同的代码(例如这里的method1和method2),也会被同步顺序访问。
明白了synchronized保护的是对象而非方法后,我们就能理解多个线程是可以同时执行同一个synchronized实例方法的,只要它们访问的对象是不同的,例如这里再实例化了一个test1对象,那么同时调用test和test1的method1方法是可行的。此外,synchronized方法不能防止非synchronized方法被同时执行,因此一般在保护变量时,需要在所有访问该变量的方法上加上synchronized。
2)synchronized同样可以用于修饰静态方法(类方法),例如:
public class SynchronizedTest {
public static synchronized void method1(){
System.out.println("Method 1 start");
try {
System.out.println("Method 1 execute");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public static synchronized void method2(){
System.out.println("Method 2 start");
try {
System.out.println("Method 2 execute");
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test = new SynchronizedTest();
final SynchronizedTest test2 = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test2.method2();
}
}).start();
}
}
执行结果如下:
Method 1 start
Method 1 execute
Method 1 end
Method 2 start
Method 2 execute
Method 2 end
前面说过synchronized保护的是对象,那么对实例方法而言保护的是当前实例对象this,对静态方法(类方法)而言,保护的是类对象(这里是SynchronizedTest.class),因为静态方法本质上是属于类的方法,而不是对象上的方法。
所以即使这里test和test2属于不同的对象,但是它们都属于SynchronizedTest类的实例,所以也只能顺序的执行method1和method2,不能并发执行。
3)synchronized也可以用于修饰代码块,例如:
public class SynchronizedTest {
public void method1(){
System.out.println("Method 1 start");
try {
synchronized (this) {
System.out.println("Method 1 execute");
Thread.sleep(3000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 1 end");
}
public void method2(){
System.out.println("Method 2 start");
try {
synchronized (this) {
System.out.println("Method 2 execute");
Thread.sleep(1000);
}
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Method 2 end");
}
public static void main(String[] args) {
final SynchronizedTest test = new SynchronizedTest();
new Thread(new Runnable() {
@Override
public void run() {
test.method1();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
test.method2();
}
}).start();
}
}
执行结果如下:
Method 1 start
Method 1 execute
Method 2 start
Method 1 end
Method 2 execute
Method 2 end
synchronized括号里面的就是保护的对象,对于实例方法就是this,{}里面就是同步执行的代码。在这个例子中,线程1和线程2都进入了对应的方法开始执行,但是线程2在进入同步块之前,需要等待线程1中同步块执行完成。
3.1.2 synchronized特性
synchronized有几个比较重要的特征:
- 可重入性
所谓的可重入性就是:对同一个执行线程,它在获得了锁之后,在调用其他需要同样锁的代码时,可以直接调用,比如说,在一个synchronized实例方法内,可以直接调用其他synchronized实例方法。之后会介绍可重入锁,因为synchronized具有可重入性,因此synchronized关键字锁都是可重入的。 - 内存可见性
synchronized除了可以保证原子操作外,它还有一个重要的作用,就是保证内存可见性,在释放锁时,所有写入都会写回内存,而获得锁后,都会从内存中读最新数据。 - 死锁
使用synchronized或者其他锁,要注意死锁,所谓死锁就是类似这种现象,比如,有a, b两个线程,a持有锁A,在等待锁B,而b持有锁B,在等待锁A,a与b陷入了互相等待,最后谁都执行不下去形成了死锁。因此应该尽量避免在持有一个锁的同时去申请另一个锁,如果确实需要多个锁,所有代码都应该按照相同的顺序去申请锁。不过,在复杂的项目代码中,这种约定可能难以做到。还有一种方法是使用显式锁接口Lock,它支持尝试获取锁(tryLock)和带时间限制的获取锁方法,使用这些方法可以在获取不到锁的时候释放已经持有的锁,然后再次尝试获取锁或干脆放弃,以避免死锁。 - 有序性:synchronized保证一个变量在同一个时刻只允许一条线程对其进行lock操作,使得持有同一个锁的两个同步块只能串行地进入。
3.1.3 synchronized原理
先来看下Synchronized是如何实现对代码块和方法进行同步的,参考 liuxiaopeng-Java并发编程系列。
1)对于代码块来说:
public class SynchronizedDemo {
public void method() {
synchronized (this) {
System.out.println("Method 1 start");
}
}
}
反编译结果如下:
由反编译结果可以看出,对于方法块来说,synchronized是通过monitorenter和monitorexit这两个字节码指令实现的:
- monitorenter
每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:
1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;
2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1;
3、如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。 - monitorexit
执行monitorexit的线程必须是objectref所对应的monitor的所有者。指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。
也就是说Synchronized底层其实是通过一个monitor的对象来完成的,这里要回答我们之前提出的一个问题:wait()、notify()、notifyAll()这三个方法为什么位于Object类中,并且还都必须在synchronized 同步关键字所限定的作用域中调用呢?其实wait/notify/notifyAll方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify/notifyAll等方法。位于Object类中的原因也是一样的,wait等待的其实是对象monitor,由于Java中的每一个对象都有一个内置的monitor对象 (这个对象其实就是 Java 对象的锁,通常会被称为“内置锁”或“对象锁”。针对每个类也有一个锁,可以称为“类锁”,类锁实际上是通过对象锁实现的,即类的 Class 对象锁。每个类只有一个Class对象,所以每个类只有一个类锁),自然所有的类都会有wait/notify/notifyAll方法。
2)对于方法来说:
public class SynchronizedMethod {
public synchronized void method() {
System.out.println("Hello World!");
}
}
反编译结果如下:
由反编译结果来看,方法的同步并没有通过monitorenter和monitorexit指令来完成,而是在其常量池中多了ACC_SYNCHRONIZED标示符,JVM就是根据该标示符来实现方法的同步的:当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程将先获取monitor,获取成功之后才能执行方法体,方法执行完后再释放monitor。在方法执行期间,其他任何线程都无法再获得同一个monitor对象。
3.2 volatile
前面在说可见性问题的时候说过,当把变量声明为 volatile 类型后,编译器与运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步,因此在读取 volatile 类型的变量时总会返回最新写入的值。这里我们简要从特性和原理两方面阐述。
3.2.1 volatile特性
- volatile关键字可以保证此变量对所有的线程的可见性,但不一定能保证它具有原子性,这里说的不一定能指的是:对volatile变量的单次读或单次写操作可以保证原子性的,如long和double类型变量,但是并不能保证i++这种操作的原子性,因为本质上i++是读、写两次操作。
- volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
3.2.2 volatile原理
对于可见性的实现来说:
- 修改volatile变量时会强制将修改后的值刷新的主内存中,并且再读取该变量值的时候就需要重新从读取主内存中的值。
此外,还有个内存屏障的概念,为了实现volatile可见性,JVM底层是通过一个叫做“内存屏障”的东西来完成。也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
- LoadLoad 屏障
执行顺序:Load1—>Loadload—>Load2
确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据。 - StoreStore 屏障
执行顺序:Store1—>StoreStore—>Store2
确保Store2以及后续Store指令执行前,Store1操作的数据对其它处理器可见。 - LoadStore 屏障
执行顺序: Load1—>LoadStore—>Store2
确保Store2和后续Store指令执行前,可以访问到Load1加载的数据。 - StoreLoad 屏障
执行顺序: Store1—> StoreLoad—>Load2
确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。
3.3 锁&锁优化
Java中有两种加锁的方式:一种是用前面介绍的synchronized关键字,另一种是用显示锁接口Lock的实现类。我们来主要了解下后者显式锁:
public interface Lock {
void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();
}
ReentrantLock、ReadLock、WriteLock是显式锁Lock接口最重要的三个实现类,对应了“可重入锁”、“读锁”和“写锁” ,简要介绍下方法:
方法 | 说明 |
---|---|
lock() | 用来获取锁,如果锁被其他线程获取,处于等待状态。如果采用 Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在 try{}catch{} 块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生 |
lockInterruptibly() | 通过这个方法去获取锁时,如果线程正在等待获取锁,则这个线程能够响应中断,即中断线程的等待状态 |
tryLock() | 它表示用来尝试获取锁,如果获取成功,则立即返回 true,如果获取失败(即锁已被其他线程获取),则立即返回 false,在获取不到锁时不会一直等待 |
tryLock(long time, TimeUnit unit) | 与 tryLock 类似,只不过是有等待时间,在等待时间内获取到锁返回 true,超时返回 false,不会立即返回结果 |
newCondition() | 新建一个条件,一个Lock可以关联多个条件 |
形象地说,前面介绍的synchronized关键字像是自动档,可以满足日常驾驶需求。但是如果想要玩漂移或者各种骚操作,就需要换手动档-即各种Lock的实现类。从上述方法也可以看出可以看出,相比synchronized,显式锁支持以非阻塞方式获取锁、可以响应中断、可以限时,这使得它灵活的多。
此外,对于Java锁的分类也没有严格意义的规则,我们常说的分类一般都是依据锁的特性、锁的设计、锁的状态等进行分类的,如下图所示。这里还要打消一个错误思想就是一个锁只能属于一种分类。其实并不是这样,比如一个锁可以同时是悲观锁、可重入锁、公平锁、可中断锁等,就像一个人在不同场景下可以被分为学生、健身爱好者、游戏玩家等。
- 从是否锁住同步资源角度:悲观锁与乐观锁
- 悲观锁(PessimisticLock):每次去拿数据的时候都认为别人会修改。所以每次在拿数据的时候都会上锁。这样别人想拿数据就被挡住,直到悲观锁被释放。
- 乐观锁(Optimistic Lock):每次去拿数据的时候都认为别人不会修改。所以不会上锁,不会上锁!但是如果想要更新数据,则会在更新前检查在读取至更新这段时间别人有没有修改过这个数据。如果修改过,则重新读取,再次尝试更新,循环上述步骤直到更新成功。乐观锁在 Java 中是通过使用无锁编程来实现,最常采用的是 CAS 算法,譬如Java 原子类中的递增操作就通过 CAS 自旋实现。
- 由此可以看出悲观锁适合写操作非常多的场景;乐观锁适合读操作非常多的场景,不加锁会带来大量的性能提升。
- 自旋锁
相对于互斥锁的概念,互斥锁线程会进入 WAITING 状态和 RUNNABLE 状态的切换,涉及上下文切换、cpu 抢占等开销(这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长,这种情况下阻塞就显得得不偿失),自旋锁的线程一直是 RUNNABLE 状态的,一直在那循环检测锁标志位,机制不重复,但是自旋锁加锁全程消耗 cpu,起始开销虽然低于互斥锁,但随着持锁时间加锁开销是线性增长。
详细解释来说,如果物理机器有多个处理器,能够让两个或以上的线程同时并行执行,我们就可以让后面那个请求锁的线程不放弃CPU的执行时间,看看持有锁的线程是否很快就会释放锁。而为了让当前线程“稍等一下”,我们需让当前线程进行自旋,如果在自旋完成后前面锁定同步资源的线程已经释放了锁,那么当前线程就可以不必阻塞而是直接获取同步资源,从而避免切换线程的开销。简单来说就是用短时间的忙等,换取线程在用户态和内核态之间切换的开销,这就是自旋锁。
但是自旋锁本身是有缺点的,它不能代替阻塞,自旋等待虽然避免了线程切换的开销,但它要占用处理器时间。如果锁被占用的时间很短,自旋等待的效果就会非常好。反之如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数(默认是 10 次,可以使用-XX:PreBlockSpin 来更改)没有成功获得锁,就应当挂起线程。 - 从锁升级的角度:无锁->偏向锁->轻量级锁->重量级锁
前面提到Synchronized是通过对象内部的一个叫做监视器锁(monitor)来实现的,而监视器锁本质又是依赖于底层的操作系统的Mutex Lock(互斥锁)来实现的。操作系统实现线程之间的切换这就需要从用户态转换到核心态,成本高、时间长、效率低。因此这种依赖于操作系统Mutex Lock所实现的锁被称为“重量级锁”。JDK中对Synchronized做的各种优化都是为了减少这种重量级锁的使用。JDK1.6以后,为了减少获得锁和释放锁所带来的性能消耗,在无锁与重量级锁状态之间引入了“轻量级锁”和“偏向锁”。锁的具体状态会随着竞争情况而逐渐升级,锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后无法降为偏向锁。- 偏向锁:初次执行到synchronized代码块的时候,锁对象变成偏向锁,意思是“偏向于第一个获得它的线程”的锁。执行完同步代码块后,线程并不会主动释放偏向锁(除非遇到其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁)。当第二次到达同步代码块时,线程会判断此时持有锁的线程是否就是自己,如果是则正常往下执行。由于之前没有释放锁,这里也就不需要重新加锁,自始至终使用锁的线程只有一个,性能极高。
- 轻量级锁:一旦有第二个线程加入锁竞争(当某线程尝试获取锁的时候,发现该锁已经被占用,只能等待其释放,就发生了锁竞争),偏向锁就升级为轻量级锁(这里的轻量级锁也是一种自旋锁)。在轻量级锁状态下继续锁竞争,没有抢到锁的线程将自旋,即不停地循环判断锁是否能够被成功获取。获取锁的操作,其实就是通过CAS修改对象头里的锁标志位。先比较当前锁标志位是否为“释放”,如果是则将其设置为“锁定”,比较并设置是原子性发生的。这就算抢到锁了,然后线程将当前锁的持有者信息修改为自己。长时间的自旋操作是非常消耗资源的,一个线程持有锁,其他线程就只能在原地空耗CPU,执行不了任何有效的任务,这种现象叫做忙等(busy-waiting)。如果多个线程用一个锁,但是没有发生锁竞争,或者发生了很轻微的锁竞争,那么synchronized就用轻量级锁,允许短时间的忙等现象。以短时间的忙等,换取线程在用户态和内核态之间切换的开销。如果锁竞争情况严重,某个达到最大自旋次数的线程,会将轻量级锁升级为重量级锁(依然是CAS修改锁标志位,但不修改持有锁的线程ID)。当后续线程尝试获取锁时,发现被占用的锁是重量级锁,则直接将自己挂起(而不是忙等),等待将来被唤醒。
- 从多线程竞争时是否要排队角度:公平锁和非公平锁
- 公平锁:如果多个线程申请一把公平锁,那么当锁释放的时候,先申请的先得到,非常公平。
- 非公平锁:如果是非公平锁,后申请的线程可能先获取到锁,是随机或者按照其他优先级排序的。对ReentrantLock类而言,通过构造函数传参可以指定该锁是否是公平锁,默认是非公平锁。一般情况下,非公平锁的吞吐量比公平锁大,如果没有特殊要求,优先使用非公平锁。对于synchronized而言,它也是一种非公平锁,但是并没有任何办法使其变成公平锁。
- 从一个线程中的多个流程能不能获取同一把锁:可重入锁与不可重入锁
前面提到过可重入性的概念。可重入锁又叫“递归锁”,即允许同一个线程多次获取同一把锁。比如一个递归函数里有加锁操作,递归过程中这个锁如果不会阻塞自己,那么这个锁就是可重入锁,否则就是不可重入锁。Java里只要以Reentrant开头命名的锁都是可重入锁,而且JDK提供的所有现成的Lock实现类,包括前面说过的synchronized关键字锁都是可重入的。 - 从多线程是否能共享一把锁:共享锁与排他锁
也叫读锁(共享锁)与写锁(排他锁)。举个例子,如果我读取值是为了更新它,那么加锁的时候就直接加写锁,我持有写锁的时候别的线程无论读还是写都需要等待;如果我读取数据仅为了前端展示,那么加锁时就明确地加一个读锁,其他线程如果也要加读锁,不需要等待,可以直接获取。
这里我们就分析下ReentrantLock可重入锁与synchronized的一些区别,前面也提到了一些:ReentrantLock可重入锁实现了Lock接口,有两个构造方法:
public ReentrantLock()
public ReentrantLock(boolean fair)
其中参数fair表示是否保证公平锁,不指定的情况下,默认为false,表示不保证公平。这里是和内置锁synchronized的一个区别,synchronized非公平锁。ReentrantLock还使用了tryLock(),可以避免synchronized可能出现死锁的问题。此外,在显示条件方面也不相同,显式条件与显式锁配合,wait/notify与synchronized配合,具体参考:Java编程的逻辑 - 显式条件。
4. 线程协作机制
多线程之间除了竞争,还经常需要相互协作,前面提到的wait/notify就是Java中多线程协作的基本机制。
4.1 协作场景
多线程之间需要协作的场景有很多,比如说:
- 生产者/消费者协作模式:这是一种常见的协作模式,生产者线程和消费者线程通过共享队列进行协作,生产者将数据或任务放到队列上,而消费者从队列上取数据或任务,如果队列长度有限,在队列满的时候,生产者需要等待,而在队列为空的时候,消费者需要等待。
- 同时开始:类似运动员比赛,在听到比赛开始枪响后同时开始,在一些程序,尤其是模拟仿真程序中,要求多个线程能同时开始。
- 等待结束:主从协作模式也是一种常见的协作模式,主线程将任务分解为若干个子任务,为每个子任务创建一个线程,主线程在继续执行其他任务之前需要等待每个子任务执行完毕。
- 异步结果:在主从协作模式中,主线程手工创建子线程的写法往往比较麻烦,一种常见的模式是将子线程的管理封装为异步调用,异步调用马上返回,但返回的不是最终的结果,而是一个一般称为Promise或Future的对象,通过它可以在随后获得最终的结果。
- 集合点:类似于学校或公司组团旅游,在旅游过程中有若干集合点,比如出发集合点,每个人从不同地方来到集合点,所有人到齐后进行下一项活动,在一些程序,比如并行迭代计算中,每个线程负责一部分计算,然后在集合点等待其他线程完成,所有线程到齐后,交换数据和计算结果,再进行下一次迭代。
4.2 典型协作场景实现
这里主要学习下生产者/消费者协作模式的实现,其他场景之后有时间再继续学习。在生产者/消费者模式中,协作的共享变量是队列,生产者往队列上放数据,如果满了就wait,而消费者从队列上取数据,如果队列为空也wait。
队列设计如下,仅做演示:
static class MyBlockingQueue<E> {
private Queue<E> queue = null;
private int limit;
public MyBlockingQueue(int limit) {
this.limit = limit;
queue = new ArrayDeque<>(limit);
}
public synchronized void put(E e) throws InterruptedException {
while (queue.size() == limit) {
wait();
}
queue.add(e);
notifyAll();
}
public synchronized E take() throws InterruptedException {
while (queue.isEmpty()) {
wait();
}
E e = queue.poll();
notifyAll();
return e;
}
}
MyBlockingQueue是一个长度有限的队列,长度通过构造方法的参数进行传递,有两个方法put和take。put是给生产者使用的,往队列上放数据,满了就wait,放完之后调用notifyAll,通知可能的消费者。take是给消费者使用的,从队列中取数据,如果为空就wait,取完之后调用notifyAll,通知可能的生产者。由程序可以看到,put和take都调用了wait,但它们的目的是不同的,或者说,它们等待的条件是不一样的,put等待的是队列不为满,而take等待的是队列不为空,但它们都会加入相同的条件等待队列。由于条件不同但又使用相同的等待队列,所以要调用notifyAll而不能调用notify,因为notify只能唤醒一个线程,如果唤醒的是同类线程就起不到协调的作用。
一个简单的生产者代码如下所示,向共享队列中插入模拟的任务数据:
static class Producer extends Thread {
MyBlockingQueue<String> queue;
public Producer(MyBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
int num = 0;
try {
while (true) {
String task = String.valueOf(num);
queue.put(task);
System.out.println("produce task " + task);
num++;
Thread.sleep((int) (Math.random() * 100));
}
} catch (InterruptedException e) {
}
}
}
一个简单的示例消费者代码如下所示:
static class Consumer extends Thread {
MyBlockingQueue<String> queue;
public Consumer(MyBlockingQueue<String> queue) {
this.queue = queue;
}
@Override
public void run() {
try {
while (true) {
String task = queue.take();
System.out.println("handle task " + task);
Thread.sleep((int)(Math.random()*100));
}
} catch (InterruptedException e) {
}
}
}
主程序的示例代码如下所示:
public static void main(String[] args) {
MyBlockingQueue<String> queue = new MyBlockingQueue<>(10);
new Producer(queue).start();
new Consumer(queue).start();
}
运行结果如下:
实际使用中,Java提供了专门的阻塞队列实现,包括:接口BlockingQueue和BlockingDeque、基于数组的实现类ArrayBlockingQueue、基于链表的实现类LinkedBlockingQueue和LinkedBlockingDeque、基于堆的实现类PriorityBlockingQueue。在实际系统中,应该考虑使用这些类。
参考资料: