Java的线程同步知识点总结
1 未同步的错误情况
多线程编程的话,有时候会因为同时冲突问题而导致一些未知的错误情况。
设置一个从1加到100的功能,这次先把目标数字i进行简单的封装,然后再开始测试。
public class Num {
private int i;
public Num(int i){
this.i = i;
}
public int getI() {
return i;
}
public void add() {
i++;
}
}
然后用两个线程调用这个i,进行自加直到100为止,代码并不多。
public class UnSyncThread implements Runnable{
Num num;
public UnSyncThread(Num num){
this.num = num;
}
public void run() {
while (num.getI() < 100) {
System.out.println(Thread.currentThread().getName()+ " " + num.getI());
num.add();
}
}
public static void main(String[] args) {
Num num = new Num(0);
UnSyncThread st = new UnSyncThread(num);
new Thread(st, "线程1").start();
new Thread(st, "线程2").start();
}
}
这个时候多次运行代码,会发现,有时候数字并不是按顺序增长,这个就是非常直接的冲突例子。产生原因是两个线程同时对num对象进行操作。当两个线程同时访问一个对象并修改的时候,就有可能会发生这种错误。
2. 同步监视器Monitor和Synchronized
为了防止这种冲突情况的产生,JAVA的多线程引了一个同步监视器来解决这个问题。
同步监视器:简单来说,同步监视器就是保证在同一时间内,只有一个线程能执行指定的代码块。
Synchronized是Java内置的一种线程同步的关键字,它可以保证在同一时刻,只有一个线程可以执行指定的方法或者代码块。
主要有两个分类:
- 对象锁:锁是传入的对象。
- 类锁:锁是传入的class文件。
三种使用方法:
- 同步代码块:锁是括号中的对象,当传入的是实例对象是,是对象锁;传入的是类文件是,是类锁。
- 普通同步方法:锁是调用该方法的对象,是对象锁。
- 静态同步方法:锁是方法所在的类,是类锁。
2.1 同步代码块
用synchronized(obj)来使用同步监视器,将代码改成如下形式:
public class Sync1Test implements Runnable{
Num num;
public Sync1Test(Num num){
this.num = num;
}
public void run() {
Object o = new Object();
while (num.getI() < 100) {
synchronized(num) {
System.out.println(Thread.currentThread().getName()+ " " + num.getI());
num.add();
}
}
}
public static void main(String[] args) {
Num num = new Num(0);
UnSyncThread s1 = new UnSyncThread(num);
new Thread(s1, "线程1").start();
new Thread(st, "线程2").start();
}
}
这一段的意义是,即将执行print和add语句的线程在执行之前,会先锁定num这个对象,以保证在执行接下来这段代码的时候,只有该线程能访问这个对象。在执行完之后,再解除num的锁定,保证其他线程也能访问num对象。
- 加锁:对num对象上锁,保证只有获得锁的线程能访问对象。
- 修改:对num进行修改,这里就是print和add。
- 释放锁:完成修改后,释放锁让其他线程能够访问num的内容。
2.2 同步普通方法
用synchronized来修饰方法,将run方法回退到之前的版本,然后修改Num类的方法。
class Num {
private int i;
public Num(int i){
this.i = i;
}
public int getI() {
return i;
}
public synchronized void add() {
i++;
}
}
public class Sync2Test implements Runnable{
Num num;
public Sync2Test(Num num){
this.num = num;
}
public void run() {
while (num.getI() < 100) {
System.out.println(Thread.currentThread().getName()+ " " + num.getI());
num.add();
}
}
public static void main(String[] args) {
Num num = new Num(0);
Sync2Test s2 = new Sync2Test(num);
new Thread(s2, "线程1").start();
new Thread(s2, "线程2").start();
}
}
对于用synchronized修饰的方法,不需要显式指定同步监视器,同步监视器默认是调用该方法的对象,也就是this。在本例中就是生成的num对象。
这么一来,在run()中想要num.add()之前,就要先对num加锁,才能执行,执行之后会再释放锁。
2.3 同步静态方法
class Num {
private static int i=0;
public static int getI() {
return i;
}
public synchronized static void add() {
i++;
}
}
public class Sync3Test implements Runnable{
public void run() {
while (Num.getI() < 100) {
System.out.println(Thread.currentThread().getName()+ " " + Num.getI());
Num.add();
}
}
public static void main(String[] args) {
Sync3Test s3 = new Sync3Test();
new Thread(s3, "线程1").start();
new Thread(s3, "线程2").start();
}
}
静态方法是依附于类而不是对象的,当synchronized修饰静态方法时,锁是class对象。
3. 同步锁
JAVA5开始提供了另一种比较方便的同步锁功能,通过Lock对象来锁定要执行的代码块。
public class Num {
//实例化一个对象锁
private ReentrantLock lock = new ReentrantLock();
......
public void add() {
//在执行代码块前先上锁
lock.lock();
//对需要执行的代码内容保证线程同步
try{
i++;
}
//finally保证无论如何最后都能释放锁
finally{
lock.unlock();
}
}
.......
}
和同步一样,在上锁之后,只有获得锁的线程才能继续执行内部代码块。
4. 死锁
当两个线程同时在等待对方释放锁的时候,就会产生死锁。
这个时候线程1在等线程2释放锁,线程2同时也在等线程1释放锁,双方没有一个人愿意主动,那么程序就无法往下继续推进,这就是死锁的一个例子。
JAVA本身无法检测出死锁,也就是说就算出现了死锁程序也不会报错,但是程序相当于是无法继续往下推进。
public class DeadLock {
public static String str1 = "str1";
public static String str2 = "str2";
public static void main(String[] args){
Thread a = new Thread1( );
Thread b = new Thread2();
a.start();
b.start();
}
}
class Thread1 extends Thread{
public void run(){
try {
synchronized (DeadLock.str1) {
System.out.println(getName() + "锁住了" + DeadLock.str1);
Thread.sleep(3000); // 给点缓冲时间让线程2锁住str2
synchronized (DeadLock.str2) {
System.out.println(getName() + "锁住了" + DeadLock.str2);
}
}
} catch (Exception e) {
}
}
}
class Thread2 extends Thread{
public void run(){
try {
synchronized (DeadLock.str2) {
System.out.println(getName() + "锁住了" + DeadLock.str2);
Thread.sleep(3000); // 给点缓冲时间让线程1锁住str1
synchronized (DeadLock.str1) {
System.out.println(getName() + "锁住了" + DeadLock.str1);
}
}
} catch (Exception e) {
}
}
}
代码不复杂,让Thread1先锁住str1,再休息3s之后再锁住str2,然后Thread2操作相反。运行结果如下:
本应该有四行的输出结果但是等了好久却也只有两行,这就是双方互相锁住又在互相等待解锁的情况。
双方互相持有一个锁,但是同时又想再申请一个锁,但是一个对象同一时间只能有一个锁,所以就这样僵持住了。
如何避免死锁还请看我的这篇文章:
Java的多线程如何避免死锁_wodongx123的博客-CSDN博客
https://blog.csdn.net/qq_41872247/article/details/102503812
5. 传统线程通信
线程之间不是单独工作,它们也需要在工作的时候经常的交换信息,为了实现这种功能,Object类(也就是所有类的父类)提供了wait(),notify()和notifyAll()方法。
这三个方法必须由同步监视器来调用,而不是线程。
- wait(): 让当前线程释放对象锁并进入堵塞状态,类似join()一样有三个重载方法
- notify(): 随机唤醒一个该同步监视器上正在wait的线程,让对方变成就绪状态,但不是立刻唤醒,而是在本线程释放同步监视器的锁之后(比如完成代码块或者wait())才唤醒。
- notifyAll(): 唤醒所有该同步监视器上正在wait的线程,在本线程完成释放同步监视器的锁之后进入就唤醒它们。
还是之前那个数一数的例子,当时在使用同步代码块的时候,运行结果还有一点小问题,就是当时运行的结果是线程1连续一段,线程2连续一段。现在就通过线程通信的方式,改成线程1和线程2轮流数数到100,这么改。
public void run() {
while (num.getI() < 100) {
//synchronized(obj){} 会将obj作为同步监视器
synchronized (num) {
System.out.println(Thread.currentThread().getName()+ " " + num.getI());
num.add();
//唤醒线程
num.notify();
try {
//释放对象锁
num.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
然后简单的画了个流程图用来参考,方块表示状态,箭头表示执行的内容。
到此就把上面的线程生命周期上所有的方法都讲了一遍。
6. 其他
6.1 线程安全和非线程安全
当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么这个类就是线程安全的。
反之,运行环境需要采用特定的调度方式或者加主调代码需要额外的同步和协同,这个类就是非线程安全的。
也就是说,线程安全的这个意义不是针对线程,而是针对类,你可以说XX类是线程安全的,但是不能说XX线程是线程安全的。
参考材料
【Java并发编程之深入理解】Synchronized的使用_java_张静怡-CSDN博客
https://blog.csdn.net/zjy15203167987/article/details/82531772
synchronized关键字以及对象锁和类锁的区别 - 兰亭笑笑声 - 博客园
https://www.cnblogs.com/wpf-7/p/9639671.html