java多线程(二):synchronize和锁
一、多线程情况下的线程安全问题
先理解一个概念:
- 线程安全:多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的。
- 线程不安全:就是不提供数据访问保护,有可能出现多个线程先后更改数据造成所得到的数据是脏数据
简单的说,就是如果你的代码所在的进程中有多个线程在同时运行,而这些线程可能会同时运行这段代码。如果每次运行结果和单线程运行的结果是一样的,而且其他的变量的值也和预期的是一样的,就是线程安全的,否则就是线程不安全。
举个例子:
//公共变量
int val = 1000;
public void runThread() {
Runnable runnable = new Runnable() {
@Override
public void run() {
while (val > 0) {
System.out.println(Thread.currentThread().getName() + ":减一后现在val还剩 " + --val);
}
}
};
new Thread(runnable).start();
new Thread(runnable).start();
new Thread(runnable).start();
}
//执行结果
...
Thread-2:减一后现在val还剩 89 //重复
Thread-1:减一后现在val还剩 89
Thread-1:减一后现在val还剩 98 //乱序
Thread-2:减一后现在val还剩 96
Thread-0:减一后现在val还剩 97
...
这显然不是我们想要的效果,因此,当某个线程操作的的时候,需要引入锁来限制其他线程对变量的操作,保证线程安全。
二、Synchronize 关键字
1. 什么是synchronize
synchronized() 可以在任意对象及方法上加锁,而加锁的这段代码称为"互斥区"或"临界区",当多个线程访问被修饰后的方法时,会按CPU分配的先后顺序排队进行处理,同一时间只有持有锁的那一条线程可以访问。
一个线程想要执行synchronized关键字修饰的方法里的代码:
- 尝试获得锁
- 如果拿到锁,执行synchronized代码体内容
- 拿不到锁,这个线程就会不断的尝试获得这把锁,直到拿到为止
2. 使用synchronize加锁
Synchronize关键字可以用来修饰代码块或方法,这也是最常见的用法:
synchronized(lockObject){
//代码块
}
public synchronize void lockMethod() {
//普通非静态方法
}
现在回过头,用synchronize修饰原有开头举例的代码中后:
Runnable runnable = new Runnable() {
@Override
public void run() {
//使用synchronized加锁
synchronized (SourceConfilct.class){
while (val > 0) {
System.out.println(Thread.currentThread().getName() + ":减一后现在val还剩 " + --val);
}
}
}
};
//执行结果
...
Thread-2:减一后现在val还剩 3
Thread-2:减一后现在val还剩 2
Thread-2:减一后现在val还剩 1
Thread-0:减一后现在val还剩 0
问题解决了!
3. 对象锁和类锁
实现同步的方法是使用synchronize关键字给代码加锁,锁对象的选择也会影响同步。
java中的每个对象都可以作为锁,一般由三种形式:
- 对于普通同步方法,锁是当前实例对象(this)
- 对于同步方法块,锁是Synchonized括号里配置的对象(obj)
- 对于静态同步方法/方法块,锁是当前类的Class对象(xxx.Class)
前两者的锁叫对象锁,第三种锁叫类锁。
3.1 对象锁
对于普通同步方法和同步方法块,可以这么理解:
对于普通的非静态同步方法,他的锁就是这个实例对象本身,同一个实例里的其他非静态同步方法要执行都必须要先等锁释放再获取锁,也就是说,在同一实例对象里,非静态同步方法们争夺同一把锁,是串行的,而多个实例对象间的非静态同步方法争夺的锁不是同一把,所以是并行的。
对于同步方法块,跟普通的非静态同步方法差不多,只不过锁对象是自己指定的(当然,作为锁的对象最好是一个公共变量,如果每次执行代码都new一个的话等于每次都获取一个新锁,是不会有同步的效果的)。
他们的锁都是针对一个对象的锁,所以可以理解为对象锁。
3.2 类锁
那么问题来了,如果有一个类里有一个静态方法,这个类里还有一个内置的方法调用了这个今天方法,我直接调用该方法,跟new一个实例以后再间接调用会同步吗?
为了解决这个问题,针对静态方法这样特殊的“世界公民”,我们需要一个能在任何时候都唯一的锁来限制他,而独一无二的类就是一个好选择。
对于静态同步方法/方法块,他的锁是一个Class对象,而无论有多少个实例,Class只有一个,也就是说,使用Class作为锁的时候,不管线程访问的实例是不是一个,他们都会争夺同一把锁,只要执行的这一段代码,都要乖乖排队,是直接在全局锁住了这段代码,
而静态同步方法/方法块的锁是一个类,所以叫类锁。
synchronized(Obj.Class){
//使用了类锁的代码块
}
public static synchronized void lockMethod() {
//静态同步方法
}
对象锁是用于对象实例方法,或者一个对象实例上的,类锁是用于类的静态方法或者一个类的class对象上的。我们知道,类的对象实例可以有很多个,但是每个类只有一个class对象,所以不同对象实例的对象锁是互不干扰的,但是每个类只有一个类锁。
但是有一点必须注意的是,其实类锁只是一个概念上的东西,并不是真实存在的,它只是用来帮助我们理解锁定实例方法和静态方法的区别的
三、死锁
当线程互相持有对方所需要锁,就会导致线程永远等待获取获取不到的锁,从而导无法释放资源,这种情况称为死锁
1.产生死锁的必要条件
- 互斥条件:进程要求对所分配的资源进行排它性控制,即在一段时间内某资源仅为一进程所占用。
- 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
- 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
- 环路等待条件:在发生死锁时,必然存在一个进程--资源的环形链。
举个例子:
public void runThread() {
getDeadLock();
}
void getDeadLock() {
Runnable runnable1 = () -> {
synchronized ("A") {
System.out.println("线程1持有了A");
//休眠一小段时间让2有时间获得锁B
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized ("B") {
System.out.println("线程1持有了A和B!");
}
}
};
Runnable runnable2 = () -> {
synchronized ("B") {
System.out.println("线程2持有了B");
//休眠一小段时间让1有时间获得锁A
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized ("A") {
System.out.println("线程2持有了B和A!");
}
}
};
new Thread(runnable1).start();
new Thread(runnable2).start();
}
执行后,由于锁B被线程2持有,所有线程1获取不到锁B无法继续执行,也就无法释放资源,线程2亦然
2.解决死锁的基本方法
- 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
- 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件)
- 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
- 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)
3.wait(),notify()
- wait:当前线程释放自己的锁标记,让出CPU资源,使得当前线程进入等待队列中
- notify:唤醒等待该锁的队列中的线程,使得该线程进入锁池
- notifyAll:唤醒等待该锁的队列中的所有线程,使其进入锁池
使用wait和notify让线程及时释放锁:
void getDeadLock() {
Runnable runnable1 = () -> {
synchronized ("A") {
System.out.println("线程1持有了A");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//线程1先释放A锁进入等待队列
try {
"A".wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized ("B") {
System.out.println("线程1持有了A和B!");
}
}
};
Runnable runnable2 = () -> {
synchronized ("B") {
System.out.println("线程2持有了B");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized ("A") {
System.out.println("线程2持有了B和A!");
//线程2获得A锁后,释放A锁并唤醒线程1
"A".notify();
}
}
};
new Thread(runnable1).start();
new Thread(runnable2).start();
}
四、synchronize的底层实现
先简单理解一下锁是怎么实现的:
通过反编译class文件查看字节码
public class Test {
private static Object LOCK = new Object();
public static int main(String[] args) {
synchronized (LOCK){
System.out.println("Hello World");
}
return 1;
}
}
可知对于JVM锁是通过monitorenter
和monitorexit
两个指令来实现的:
可以把执行monitorenter指令理解为加锁,执行monitorexit理解为释放锁。 每个对象维护着一个记录着被锁次数的计数器。未被锁定的对象的该计数器为0,当一个线程获得锁(执行monitorenter)后,该计数器自增变为 1 ,当同一个线程再次获得该对象的锁的时候,计数器再次自增。当同一个线程释放锁(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。锁将被释放,其他线程便可以获得锁。
大致意思是,每个对象都有一个monitor监视器,调用monitorenter就是尝试获取这个对象,成功获取到了就将值+1,离开就将值减1。如果是线程重入,在将值+1,说明monitor对象是支持可重入的。
如果synchronize在方法上,那就没有上面两个指令,取而代之的是有一个ACC_SYNCHRONIZED
修饰,表示方法加锁了。它会在常量池中增加这个一个标识符,获取它的monitor,所以本质上是一样的。
参考:再有人问你synchronized是什么,就把这篇文章发给他
总结一下:
- 非静态同步方法/代码块通过
monitorenter
和monitorexit
两个指令实现加锁 - 而静态方法通过
ACC_SYNCHRONIZED
修饰方法实现加锁
五、synchronize与三大性质
1.原子性
原子性是指一个操作是不可中断的,要全部执行完成,要不就都不执行。
线程是CPU调度的基本单位。CPU有时间片的概念,会根据不同的调度算法进行线程调度。当一个线程获得时间片之后开始执行,在时间片耗尽之后,就会失去CPU使用权。所以在多线程场景下,由于时间片在线程间轮换,就会发生原子性问题。
在Java中,为了保证原子性,提供了两个高级的字节码指令monitorenter和monitorexit。
前面介绍过,这两个字节码指令,在Java中对应的关键字就是synchronized。
通过monitorenter和monitorexit指令,可以保证被synchronized修饰的代码在同一时间只能被一个线程访问,在锁未释放之前,无法被其他线程访问到。
线程1在执行monitorenter指令的时候,会对Monitor进行加锁,加锁后其他线程无法获得锁,除非线程1主动解锁。即使在执行过程中,由于某种原因,比如CPU时间片用完,线程1放弃了CPU,但是,他并没有进行解锁。而由于synchronized的锁是可重入的,下一个时间片还是只能被他自己获取到,还是会继续执行代码。直到所有代码执行完。这就保证了原子性。
因此,在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。
2.有序性
有序性即程序执行的顺序按照代码的先后顺序执行。
除了引入了时间片以外,由于处理器优化和指令重排等,CPU还可能对输入代码进行乱序执行,比如load->add->save 有可能被优化成load->save->add 。这就是可能存在有序性问题。
这里需要注意的是,synchronized是无法禁止指令重排和处理器优化的。也就是说,synchronized无法避免上述提到的问题。
那么,为什么还说synchronized也提供了有序性保证呢?
这就要再把有序性的概念扩展一下了。
Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有操作都是天然有序的。如果在一个线程中观察另一个线程,所有操作都是无序的。
以上这句话也是《深入理解Java虚拟机》中的原句,但是怎么理解呢?周志明并没有详细的解释。这里我简单扩展一下,这其实和as-if-serial语义有关。
as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
这里不对as-if-serial语义详细展开了,简单说就是,as-if-serial语义保证了单线程中,指令重排是有一定的限制的,而只要编译器和处理器都遵守了这个语义,那么就可以认为单线程程序是按照顺序执行的。当然,实际上还是有重排的,只不过我们无须关心这种重排的干扰。
所以呢,由于synchronized修饰的代码,同一时间只能被同一线程访问。那么也就是单线程执行的。所以,可以保证其有序性。
3.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
Java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。所以,就可能出现线程1改了某个变量的值,但是线程2不可见的情况。
前面我们介绍过,被synchronized修饰的代码,在开始执行时会加锁,执行完成后会进行解锁。
而为了保证可见性,有一条规则是这样的:对一个变量解锁之前,必须先把此变量同步回主存中。这样解锁后,后续线程就可以访问到被修改后的值。
所以,synchronized关键字锁住的对象,其值是具有可见性的。