1. 概述
- Java
内置锁
是一个互斥锁,最多只有一个线程能够获得该锁,当线程B尝试去获得线程A持有的内置锁时,线程B必须等待或者阻塞,直到线程A释放这个锁,如果线程A不释放这个锁,那么线程B将永远等待下去。 - Java中每个对象都可以用作锁,这些锁被称为
内置锁
。线程进入同步代码块或方法时会自动获得该锁,在退出同步代码块或方法时会释放该锁。获得内置锁的唯一途径就是进入被这个锁保护的同步代码块或方法。
1.1 线程安全问题
- 什么是线程安全呢?
- 当多个线程并发访问某个Java对象(Object)时,无论系统如何调度这些线程,也不论这些线程如何交替操作,这个对象都能表现出一致的、正确的行为,那么对这个对象的操作是线程安全的。如果这个对象表现出不一致的、错误的行为,那么对这个对象的操作不是线程安全的,发生了线程的安全问题。
1.2 自增运算不是线程安全的
- 对一个整数进行自增运算(
++
),怎么可能不是线程安全的呢? 这可只有一个完整的操作,看上去是那么的不可分割。做个小实验: 使用10个线程,对一个共享的变量,每个线程自增100万次,看看最终的结果是不是1000万。完成这个小实验,就知道++
运算是否是线程安全的了。
public class NotSafePlus {
private Integer amount = 0;
//自增
public void selfPlus() {
amount++;
}
public Integer getAmount() {
return amount;
}
}
public class PlusTest {
final int MAX_TREAD = 10;
final int MAX_TURN = 1000;
CountDownLatch latch = new CountDownLatch(MAX_TREAD);
/**
* 测试用例:测试不安全的累加器
*/
public void testNotSafePlus() throws InterruptedException {
NotSafePlus counter = new NotSafePlus();
Runnable runnable = () ->
{
for (int i = 0; i < MAX_TURN; i++) {
counter.selfPlus();
}
latch.countDown();
};
for (int i = 0; i < MAX_TREAD; i++) {
new Thread(runnable).start();
}
latch.await();
System.out.println("理论结果:" + MAX_TURN * MAX_TREAD);
System.out.println("实际结果:" + counter.getAmount());
System.out.println("差距是:" + (MAX_TURN * MAX_TREAD - counter.getAmount()));
}
}
总计自增10000次,结果少了6823次,差距在60%左右。每一次运行,差距都是不同的。总之,从结果可以看出,对NotSafePlus的amount成员的
++
运算在多线程并发执行场景下出现了不一致的、错误的行为,自增运算符++
不是线程安全的。
-
为什么自增运算不是线程安全的呢?实际上,一个自增运算符是一个复合操作,至少包括三个JVM指令:
- 内存取值
- 寄存器增加1
- 存值到内存
这三个指令在JVM内部是独立进行的,中间完全可能会出现多个线程并发进行。比如在amount=100时,假设有三个线程同一时间读取amount值,读到的都是100,增加1后结果为101,三个线程都将结果存入到amount的内存,amount的结果是101,而不是103。
-
内存取值、寄存器增加1、存值到内存”这三个JM指令是不可以再分的,它们都具备原子性,是线程安全的,也叫原子操作。但是,两个或者两个以上的原子操作合在一起进行操作就不再具备原子性。比如先读后写,就有可能在读之后,其实这个变量被修改了,就出现了数据不一致的情况。
1.3 临界区资源与临界区代码段
-
Java工程师在进行代码开发时,常常倾向于认为代码会以线性的、串行的方式执行,容易忽视多个线程并行执行,从而导致意想不到的结果。
-
线程安全小实验展示了在多个线程操作相同资源(如变量、数组或者对象)时就可能
出现线程安全问题
。一般来说,只在多个线程对这个资源进行写操作的时候才会出现问题,如果是简单的读操作,不改变资源的话,显然是不会出现问题的。 -
临界区资源
表示一种可以被多个线程使用的公共资源或共享数据,但是每一次只能有一个线程使用它。一旦临界区资源被占用,想使用该资源的其他线程则必须等待。 -
在并发情况下,临界区资源是受保护的对象。临界区代码段(Critical Section)是每个线程中访问临界资源的那段代码,多个线程必须互斥地对临界区资源进行访问。线程进入临界区代码段之前,必须在进入区申请资源,申请成功之后进行临界区代码段,执行完成之后释放资源。
- 竞态条件(Race Conditions)可能是由于在访问临界区代码段时没有互斥地访问而导致的特殊情况。如果多个线程在临界区代码段的并发执行结果可能因为代码的执行顺序不同而出现不同的结果,我们就说这时在临界区出现了竞态条件问题。
- 前面的线程安全小实验的代码中,amount为
临界区资源
,selfPlus()
可以理解为临界区代码段
public class NotSafePlus {
// 临界区资源
private Integer amount = 0;
// 临界区代码段
public void selfPlus() {
amount++;
}
}
-
当多个线程访问临界区selfPlus()方法时,就会出现竞态条件的问题。更标准地说,当两个或多个线程竞争同一个资源时,对资源的访问顺序就变得非常关键。为了避免竟态条件的问题,必须保证临界区代码段操作必须具备排他性。这就意味着当一个线程进入Critical Section执行时,其他线程不能进入临界区代码段执行。
-
在Java中,我们可以使用
synchronized
关键字同步代码块,对临界区代码段进行排他性保护synchronized(syncobject){ //critical section }
-
在Java中,除了使用synchronized关键字还可以使用Lock显式锁实例,或者使用原子变量对临界区代码段进行排他性保护。
-
2. synchronized 关键字
- 在Java中,线程同步使用最多的方法是使用
synchronized
关键字。每个Java对象都隐含有一把锁,这里称为Java内置锁(或者对象锁、隐式锁)。使用synchronized(syncObiect)调用相当于获取syncObiect的内置锁,所以可以使用内置锁对临界区代码段进行排他性保护。
2.1 synchronized 同步方法
-
synchronized
关键字是Java的保留字,当使用synchronized关键字修饰一个方法的时候,该方法被声明为同步方法public synchronized void selfPlus(){ amount++; }
在方法声明中设置
synchronized
同步关键字,保证了方法代码执行流程是排他性的。任何时间只允许一条线程进入同步方法(临界区代码段),如果其他线程都需要执行同一个方法,那么只能等待和排队。
2.2 synchronized 同步块
- 对于小的临界区,我们直接在方法声明中设置synchronized同步关键字,可以避免竞态条件(Race Conditions)的问题。但是对于较大的临界区代码段,为了执行效率,最好将同步方法分为小的临界区代码段。
public class TwoPlus {
private int suml = 0;
private int sum2 = 0;
//同步方法
public synchronized void plus(int vall,int val2){
// 临界区代码段
this.suml += vall;
this.sum2+= val2;
}
}
- 临界区代码段包含了对两个临界区资源的操作,这两个临界区资源分别为
sum1
,sum2
。使用synchronized对plus(int val1,int val2)进行同步保护之后,进入临界区代码段的线程拥有sum1、sum2的操作权,并且是全部占用。一旦线程进入,当线程在操作sum1而没有操作sum2时,也将sum2的操作权白白占用,其他的线程由于没有进入临界区,只能看着sum2被闲置而不能去执行操作。 - 将synchronized加在方法上,如果其保护的临界区代码段包含的临界区资源(要求是相互独立的)多于一个,会造成临界区资源的闲置等待,这就会影响临界区代码段的吞吐量。为了提升吞吐量,可以将synchronized关键字放在函数体内,同步一个代码块。
synchronized(syncobject){ // 同步块而不是方法
// TODO
}
- 在synchronized同步块后边的括号中是一个syncObiect对象,代表着进入临界区代码段需要获取syncObject对象的监视锁,或者说将syncObiect对象监视锁作为临界区代码段的同步锁。每一个Java对象都有一把监视锁(Monitor),因此任何Java对象都能作为synchronized的同步锁。单个线程在synchronized同步块后边同步锁后,方能进入临界区代码段;反过来说,当一条线程获得syncObiect对象的监视锁后,其他线程就只能等待。使用synchronized同步块对上面的TwoPlus类进行吞吐量的提升改造,具体的代码如下:
public class TwoPlus {
private int suml = 0;
private int sum2 = 0;
private Integer sumlLock = new Integer(1); // 同步锁一
private Integer sum2Lock = new Integer(2); // 同步锁二
//同步方法
public void plus(int vall,int val2){
// 临界区代码段
synchronized(this.sumlLock){
this.suml += vall;
}
synchronized(this.sum2Lock){
this.sum2+= val2;
}
}
}
改造之后,对两个独立的临界区资源sum1、sum2的加法操作可以并发执行了,在某一个时刻,不同的线程可以对sum1、sum2的同时进行加法操作,提升了plus()方法的吞吐量。
在TwoPlus代码中,由于同步块1和同步块2保护着两个独立的临界区代码段,需要两把不同的syncObject对象锁,因此TwoPlus代码新加了sum1Lock和sum2Lock两个新的成员属性。这两个属性没有参与业务处理,TwoPlus仅仅利用了sum1Lock和sum2Lock的内置锁功能。
1.3 同步方法和同步代码块区别
- synchronized方法是一种粗粒度的并发控制,某一时刻只能有一个线程执行该synchronized方法
- synchronized代码块是一种细粒度的并发控制,处于synchronized块之外的其他代码是可以被多条线程并发访问的。
- 在一个方法中,并不一定所有代码都是临界区代码段,可能只有几行代码会涉及线程同步问题。所以synchronized代码块比synchronized方法更加细粒度地控制了多条线程的同步访问。
- synchronized方法和synchronized代码块有什么联系呢?在Java的内部实现上,synchronized方法实际上等同于用一个synchronized代码块,这个代码块包含了同步方法中的所有语句,然后在synchronized代码块的括号中传入this关键字,使用this对象锁作为进入临界区的同步锁。
3. 静态的同步方法
- 在Java世界里一切皆对象。Java有两种对象: Obiect实例对象和Class对象。
- 每个类运行时的类型信息用Class对象表示,它包含与类名称、继承关系、字段、方法有关的信息。JVM将一个类加载入自己的方法区内存时,会为其创建一个Class对象,对于一个类来说其Class对象也是唯一的。Class类没有公共的构造方法,Class对象是在类加载的时候由Java虚拟机调用类加载器中的defineClass方法自动构造的,因此不能显式地声明一个Class对象。
- 所有的类都是在第一次使用时被动态加载到JVM中(懒加载),其各个类都是在必需时才加载的。
- JVM为动态加载机制配套了一个判定动态加载的行为,使得类加载器首先检查这个类的Class对象是否已经被加载。如果尚未加载,类加载器会根据类的全限定名查找
.class
文件,验证后加载到JVM的方法区内存,并构造其对应的Class对象。 - 普通的synchronized实例方法,其同步锁是当前对象this的监视锁。如果某个synchronized方法是static(静态)方法,而不是普通的对象实例方法,其同步锁是字节码对象。
public class SafeStaticMethodPlus {
private static Integer amount = 0;
public static synchronized void selfPlus() {
amount++;
}
public Integer getAmount() {
return amount;
}
}
静态方法属于Class实例而不是单个Object实例,在静态方法内部是不可以访问Object实例的this引用的。所以,修饰static静态方法synchronized关键字就没有办法获得Object实例的this对象的监视锁。
实际上,使用synchronized关键字修饰static静态方法时,synchronized的同步锁并不是普通Object对象的监视锁,而是类所对应的Class对象的监视锁。为了以示区分,这里将Object对象的监视锁叫作
对象锁
,将Class对象的监视锁叫作类锁
。
synchronized关键字修饰static静态方法时,同步锁为类锁
synchronized关键字修饰普通的成员方法(非静态方法)时,同步锁为对象锁。
由于类的对象实例可以有很多,但是每个类只有一个Class实例,所以使用类锁作为synchronized的同步锁时会造成同一个JVM内的所有线程只能互斥进入临界区段。
// 对JVM内的所有线程同步
public static synchronized void selfPlus() {}
-
通过synchronized关键字所抢占的同步锁,什么时候释放呢?
- 一种场景是synchronized块(代码块或者方法)正确执行完毕,监视锁自动释放
- 另一种场景是程序出现异常,非正常退出synchronized块,监视锁也会自动释放。
所以,使用synchronized块时不必担心监视锁的释放问题。