不仅Java语言,任何编程语言都一样,两个线程同时访问同一个变量时就可能会出现线程安全问题,这是因为两个线程对变量的访问不是原子操作,在线程切换时会造成混乱。解决线程安全问题的办法就是使用线程同步,让一个有线程安全问题的非原子性操作只能串行执行。Java语法提供了synchronized
关键字实现了同步代码块,能够解决线程安全问题。除此之外,Java还提供了Lock
锁接口,能够对线程同步逻辑进行更为精细的控制,有关Lock
接口的内容将在后续章节介绍。
当然,线程同步的缺点也显而易见,就是影响了并行性能。因此,兼顾性能和安全是需要良好的软件架构设计能力的。
Java语言中,在函数(方法)定义前面加上synchronized
后,函数就会变成同步函数。我们看这样一个例子:某函数封装了一部分对变量的访问操作,这个函数是多线程运行的且操作的变量是线程共享的,我们希望这个函数是线程安全的,此时在函数定义时加上synchronized
关键字即可。
例子:
MyThread.java
package com.gacfox.demo;
public class MyThread implements Runnable {
private int i = 0;
@Override
public void run() {
add();
System.out.println(i);
}
synchronized private void add() {
i++;
}
}
Main.java
package com.gacfox.demo;
public class Main {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Thread t1 = new Thread(myThread);
Thread t2 = new Thread(myThread);
Thread t3 = new Thread(myThread);
t1.start();
t2.start();
t3.start();
}
}
上述代码,我们让add()
函数变为同步函数,这个函数在不同线程之间就只能串行执行了。两个线程都想要执行这个函数时,后一个线程必须等待前一个线程执行结束才能继续执行。实际上,线程同步在JVM底层是通过锁实现的,当线程执行到同步函数时,该线程获得对象锁,对象锁的锁定保证了同一对象上的其他线程只能等待不能执行。因此,如果把上述代码Main
改为三个线程初始化为三个对象,实际上就并不会发生线程同步,因为锁定的是三个不同对象(但是访问的也是三个不同变量,倒也并不存在线程安全问题)。
注意:同步函数锁定的不是某段代码,而是对象。再考虑这样场景:当一个对象有两个同步函数时,由于锁定了对象,因此即使这两个函数操作的是不同变量,但是他们也不得不串行执行。
多次获得一把锁:同步函数的对象锁是可重入的,也就是说:当一个线程已经通过同步函数获得对象锁时,它再次申请同一个锁时会直接再次得到该对象的锁。说白了就是同步函数内调用同步函数是没有问题的。这很符合常理,否则同步函数调用同步函数就会死锁了,我们在实际开发中用到的锁基本都是可重入锁。
出现异常:一个线程出现异常时,如果它持有锁,锁会自动释放。
同步函数不能继承:子类不能继承父类函数的同步属性,因此如果子类也想让继承下来的函数同步,就必须重写该函数并加上synchronized
关键字。
同步语句块比同步函数能够更加精细的控制锁和触发同步的位置。假如现在有这样一个函数:
synchronized private void add() throws InterruptedException {
//假设只是一些耗时操作
Thread.sleep(1000);
//真正需要同步的操作
i++;
}
由于上面代码实现的是同步函数,因此和变量访问不相关的耗时操作也必须排队执行,这显然不太符合逻辑,一种改进方法是分成两个函数,耗时操作异步,访问全局变量操作同步,这是可行的。但是使用同步语句块能更方便的解决这个问题:
private void add() throws InterruptedException {
//假设只是一些耗时操作
Thread.sleep(1000);
synchronized(this) {
//真正需要同步的操作
i++;
}
}
同步语句块和同步函数类似,都是由synchronized
定义,this
指定要锁定的对象,这里和同步函数一样锁定我们自定义的线程对象实例。同步块外的代码会并发执行,一旦进入同步块,就会试图获取对象锁,未能抢到锁的线程就要阻塞等待了。
应该锁定谁:上面代码中,我们锁定了this
,也就是自定义线程对象的实例。实际上,有些情况下我们不必这样做,有性能更好的解决方案:例如,有两个需要被多线程修改的对象a
和b
,它们都在run()
函数中,如果我们套上两个synchronized(this)
语句块,那么修改a
和修改b
这两个不冲突的操作也是同步执行的,这就不如分别使用synchronized(a)
和synchronized(b)
,这样修改a
和修改b
时,获得的是不同对象的锁,因此就不会同步了,而修改a
和另一个线程修改a
则是同一把锁,是同步的。
对于锁定对象的要求:首先,锁定对象不能是基本类型。其次,最好使用final
修饰。final
表示该引用不可更改,实际上,引用如果被更改,其他线程获得的锁就不是原来对象的锁了,那样就做不到线程同步了。此外关于String
对象,我们知道JVM对于String
有常量池,也就是说new
两个String
对象,但是其值相同,那么获得的两个引用很有可能指向同一个对象实例,但也有可能不是。这也是为什么判断String
相等必须使用.equals()
,但是经常==
结果也正确的原因。如果从String
上获得锁,就要尤其注意这个问题,不过我们也很少这样做。
synchronized(MyThread.class):我在网上看到有些人在区分synchronized(this)
和synchronized(MyThread.class)
。我们知道,Java中每个类都有Class
对象,synchronized(MyThread.class)
这种写法下同步块锁定的就是这个Class
对象,其他没有什么特别的。不管锁定的是谁,它们都遵循统一的锁同步原理,在合适的场合下锁定合适的对象即可。除此之外,对static
方法加上synchronized
锁定的默认也是Class
对象。
死锁是多线程程序中的一种常见BUG。假设我们有两个线程,线程1和线程2,两个锁a和b,线程1获得锁a同时,线程2获得锁b,两个线程并行执行一段时间,线程1未放开锁a同时要请求锁b,线程2未放开锁b同时要请求锁a,好了,死锁发生了。
MyThread.java
package com.gacfox.demo;
public class MyThread implements Runnable {
boolean flag = true;
@Override
public void run() {
if (flag) {
synchronized (Main.lock1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Main.lock2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
} else {
synchronized (Main.lock2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (Main.lock1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
}
Main.java
package com.gacfox.demo;
public class Main {
static final Object lock1 = new Object();
static final Object lock2 = new Object();
public static void main(String[] args) {
MyThread myThread1 = new MyThread();
myThread1.flag = true;
MyThread myThread2 = new MyThread();
myThread2.flag = false;
Thread t1 = new Thread(myThread1);
Thread t2 = new Thread(myThread2);
t1.start();
t2.start();
}
}
死锁是程序的BUG,上面例子我们手动编写了一个死锁,但在实际开发中死锁问题可能十分隐蔽,死锁的发生是不确定的,造成程序一直阻塞也可能不是死锁。我们怎么定位程序是否死锁了呢?此时可以用JDK提供的jstack
工具进行查看。
jstack <pid>
运行上面代码后再执行jstack
工具,最终显示如图,果然发现了死锁。