目录
前言:
synchronized
解析
可重入和不可重入问题
解析
Java中线程安全类
死锁问题
解析
解决死锁问题
解析
内存可见性
解析
volatile关键字
解析
wait,notify
解析
小结:
前言:
针对上篇文章讲到的线程安全问题,我们需要保证一些指令的原子性,在代码中可以通过加锁实现。针对于加锁,这个是有一定的开销的,还有可能导致死锁问题。因此在加锁的时候要慎重考虑。
synchronized
1)修饰普通方法是把锁加到当前引用对象上。
2)修饰静态方法是把锁加到类对象上。
3)修饰代码块,可以指定加到哪个对象上。
注意:
如果两个线程针对同一个对象加锁,就会出现锁竞争/冲突,一个线程能够获得到锁,先到的线程先获得锁。另一个线程则需要阻塞等待,直到上一个线程解锁(方法执行完),这个线程就可以回到就绪队列,才能够获取到锁。
这里加锁虽然在方法上修饰,但实际加锁都是加到对象上面的。只有两个线程针对同一个对象加锁,才会出现锁冲突。如果针对不同对象加锁,则都会获取到锁,不会产生阻塞等待。
class Cumsum {
public int a = 0;
synchronized public void add() {
a++;
}
}
public class ThreadDemo15 {
public static void main(String[] args) {
Cumsum cumsum = new Cumsum();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 5000; i++) {
cumsum.add();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 5000; i++) {
cumsum.add();
}
}
});
t1.start();
t2.start();
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(cumsum.a);
}
}
解析
上篇讲述到这段代码,不加锁运行结果是有bug的,加了锁之后就正确了。不加锁出bug的原因在上片文章有讲述到,就是典型的线程安全问题。那么为什么加了锁之后代码就是正确呢?
首先这里有两个线程t1和t2,main线程会阻塞等待,这两个线程并发执行。如果第一个线程首先获取到锁,这个锁是加到cumsum对象上的,当第二个线程在尝试获取到这个对象的锁,就会产生阻塞等待(对象是同一个)。直到上一个线程释放了这个对象的锁,这个线程才可以获取这个锁成功。获取锁成功后读取到a的值肯定是save过的,即就是正确的数值。
可重入和不可重入问题
如果一个线程在一个方法里尝试针对同一个对象加锁两次,第一次加锁成功后,第二次在尝试对这个方法加锁,就会产生阻塞等待。即就会阻塞在这个方法里,第一次加的锁没有办法释放,程序就会一直阻塞在这里,产生死锁问题。
针对给一个对象加锁两次产生的死锁问题,在java里很可能会写出这样的代码,因此synchronized就设计为可重入锁。对于这样死锁现象,可重入锁就不会产生阻塞等待,就会放过它,即代码可以正常执行。对于这样原因产生死锁问题,即就是不可重入锁。
class Add {
public static int a = 0;
synchronized public void add() {
a++;
}
synchronized public void add2() {
synchronized (this) {
a++;
}
}
}
public class ThreadDemo11 {
public static void main(String[] args) throws InterruptedException {
Add add2 = new Add();
Thread thread1 = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 5000; i++) {
add2.add();
}
}
});
Thread thread2 = new Thread(new Runnable() {
@Override
public void run() {
for(int i = 0; i < 5000; i++) {
add2.add2();
}
}
});
//执行一次a++需要,load,add,save(都内存数据到寄存器,寄存器值++, 寄存器值写回内存)
//由于两个线程是并发执行的,这些指令会随机组合(抢占式执行,随意调度),就会产生线程安全问题(不同的顺序,结果就会产生差异)
//第二个线程读取的值是在第一个线程保存后读取的1,就会加2次(线程安全)
//两次读取的值都为0,则最终只加1. 在一次线程切换中,另一个线程可能会执行多次三步流程(线程不安全)
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println(Add.a);
}
}
解析
如果第一个线程先执行,会给add2对象加锁成功。这个时候第二个线程在尝试对于这个对象加锁就会产生阻塞等待。当第一个线程释放锁之后,第二个线程就会针对于这个对象加锁成功,执行代码块的时候又会针对这个对象加锁第二次,由于synchronized是可重入锁,即在这里不会产生死锁问题,即代码就会正常执行。
Java中线程安全类
Vector HashTable ConcurrentHashMap StringBuffer 这些集合类中都内置了synchronized锁,多线程中线程是安全的。String类由于不可修改性,即天然就是线程安全的。
AyyayList LinkedList HashMap TreeMap HashSet TreeSet StringBuilder 这些集合类中在线程安全问题需要手动加锁。
死锁问题
上面说的在一个线程里给一个对象加两次锁,如果锁是不可重入锁,那么就会产生死锁。如果线程1先获取到锁A,被调度走,线程2先获取到锁B,再尝试获取锁A,就会阻塞等待,线程1调度回来获取锁B,也会阻塞等待。这个时候两个线程都在等对方释放锁,程序就会卡着不动了,产生了死锁问题。多个线程多把锁,如果每个线程都获取到锁,并且都在等对方释放锁,那么每个线程都会卡着不动,产生死锁问题。
死锁问题的核心就是循环等待,想要解决死锁问题,那么就需要打破这种循环等待。
public class ThreadDemo12 {
public static void main(String[] args) {
Object o1 = new Object();
Object o2 = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (o1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("aaaa");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (o2) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o1) {
System.out.println("bbbbb");
}
}
}
});
t1.start();
t2.start();
}
}
解析
线程1先个o1对象加锁,然后sleep(),线程2给o2加锁,然后sleep()。接下来线程1尝试获取o2的锁,线程2尝试获取o1的锁,即两个线程都会阻塞等待,产生死锁。
解决死锁问题
给锁编号,约定获取锁的顺序,从小到大或者从大到小。任意线程在加锁的时候都遵循这样的规则,就可以打破循环等待的问题,那么死锁问题也就解决了。
public class ThreadDemo12 {
public static void main(String[] args) {
Object o1 = new Object();
Object o2 = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (o1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("aaaa");
}
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
synchronized (o1) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (o2) {
System.out.println("bbbbb");
}
}
}
});
t1.start();
t2.start();
}
}
解析
线程1先获取o1再获取o2的锁,线程2也遵循这样的规则。调整了获取锁的顺序后,可以清楚看见代码正常执行了。
内存可见性
如果针对于同一个变量即读又写,那么就会涉及内存可见性问题。实质上是编译器优化导致的bug。
对于一个变量的修改,首先需要读取内存的数据到寄存器中,然后在寄存器中修改这个变量,最终写回到内存中。编译器优化可能会认为这个变量是不可变的,即在每次读数据的时候,只读取寄存器中的值,而不是修改后内存中的数据。这就导致读的数据就是修改之前的。
一个线程针对于一个变量进行修改操作,同时另一个线程针对这个变量读取操作。此时读取到的值不一定是修改后的值,这个线程没有感知到这个变量的变化。
class Counter {
//不能修饰局部变量
//局部变量在不同的线程里占用不同的栈空间,意味这就是不同的变量(每个线程都有自己的栈空间)
public int flag = 0;
}
public class ThreadDemo16 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (counter.flag == 0) {
}
System.out.println("aaaa");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
Scanner scanner = new Scanner(System.in);
counter.flag = scanner.nextInt();
}
});
t1.start();
t2.start();
}
}
解析
当t2线程修改掉flag值为2时,t1线程在while中死循环。因为t1线程读取到的值是没有修改的flag。这就是编译器优化导致认为flag是不可变的,即每次都是读取寄存器中的值,而不是t2线程修改后内存中的值。
volatile关键字
解决内存可见性,使用volatile关键字。声明这个变量是可变的,即告诉编译器在每次读取数据时,需要读取内存中的数据,而不是寄存器中的数据。这个时候编译器就不会随便优化了。
class Counter {
//不能修饰局部变量
//局部变量在不同的线程里占用不同的栈空间,意味这就是不同的变量(每个线程都有自己的栈空间)
volatile public int flag = 0;
}
public class ThreadDemo16 {
public static void main(String[] args) {
Counter counter = new Counter();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (counter.flag == 0) {
}
System.out.println("aaaa");
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
Scanner scanner = new Scanner(System.in);
counter.flag = scanner.nextInt();
}
});
t1.start();
t2.start();
}
}
解析
当给flag加上volatile关键字声明是可变的之后,while循环后的语句顺利打印了,说明t1线程读取到了t2线程修改后的值。因为voiatile修饰后,就会认为这个变量是可变的,即每次都会同步内存中的数据,即也就解决了内存可见性问题。
wait,notify
由于线程的抢占式执行,随机调度。wait可以让线程主动放弃cpu的调度,进入阻塞队列。让其他线程可以被调度,可以控制线程的调度时机。当使用wait主动放弃cpu的时候,需要其他线程通过notify来唤醒该线程,进入就绪队列。wait和notify都是Object类下的方法。
wait主动放弃cpu调度的机制,首先会释放锁,然后线程阻塞等待。那么在释放锁的时候需要有锁,即需要先得到锁然后在释放锁,进入阻塞队列。为什么这样设定呢?释放锁之后其他线程可以给这个对象加锁,就不会导致这个对象一直被加锁。wait不加任何参数就是死等。某个线程调用wait方法,就会进入阻塞队列(无论是哪个对象),此时就处在WAITING状态。
notify通知线程唤醒机制。再唤醒线程也需要获得锁才可以唤醒线程。即首先需要获取锁,然后调用notify方法,唤醒线程进入就绪队列。notify只能唤醒同一个对象调用wait所阻塞的线程,如果有多个线程都在阻塞,则随机唤醒一个。notifyAll可以全部唤醒,一起进入就绪队列。这里的notify唤醒wait不会有任何异常。
public class ThreadDemo17 {
public static void main(String[] args) throws InterruptedException {
Object object = new Object();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t1前");
synchronized (object) {
try {
object.wait(); //不加任何参数就是死等
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("t1后");
}
});
//notify只能唤醒同一个对象上的等待线程
//如果和wait对象不一致,则不生效
//多个线程wait的时候,notify随机唤醒一个,notifyAll全部唤醒,一起竞争锁
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println("t2前");
synchronized (object) {
object.notify();
}
System.out.println("t2后");
}
});
t1.start();
Thread.sleep(500);
t2.start();
}
}
解析
需要保证先启动t1线程通过synchronized先给object加上锁,然后通过wait方法释放锁,使该线程阻塞等待。当t2线程执行的时候,也是通过synchronized先给object加上锁,然后object对象调用notify方法通知t1线程,进入就绪队列。可以看见代码的执行顺序也是这样。这里wait和notify方法的调用对象需要一致,才能明确具体通知哪一个线程。
小结:
与大家共勉歌德的名言:志向和热爱是伟大行为的双翼。