Java多线程——线程之间的同步

时间:2022-06-15 07:23:34

Java多线程——线程之间的同步

摘要:本文主要学习多线程之间是如何同步的,如何使用volatile关键字,如何使用synchronized修饰的同步代码块和同步方法解决线程安全问题。

部分内容来自以下博客:

https://www.cnblogs.com/hapjin/p/5492880.html

https://www.cnblogs.com/paddix/p/5367116.html

https://www.cnblogs.com/paddix/p/5428507.html

https://www.cnblogs.com/liuzunli/p/10181869.html

https://www.cnblogs.com/zhaoyan001/p/6365064.html

多线程之间的并发问题

在使用多线程的时候,如果多个线程之间有共享的数据,并且其中一个线程在操作共享数据的时候,其他线程也能操作共享数据,那么就有可能引发线程的并发问题。

多售票窗口同时售票引发的并发问题

情景说明:

有2个售票窗口同时售卖3张车票,在这个情境中,用2个线程模拟2个售票窗口,3张车票是共享资源,可售卖的编号是1到3,从3号车票开始售卖。

如果在售票时没有考虑线程的并发问题,2个窗口都能同时修改车票资源,则很容易引发多线程的安全问题。

代码如下:

 public class Demo {
public static void main(String[] args) {
DemoThread dt = new DemoThread();
Thread t1 = new Thread(dt, "窗口1");
Thread t2 = new Thread(dt, "窗口2");
t1.start();
t2.start();
}
} class DemoThread implements Runnable {
private int ticket = 3; @Override
public void run() {
while (ticket > 0) {
System.out.println(Thread.currentThread().getName() + " 进入卖票环节 ");
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
}
}
}

运行结果如下:

 窗口1 进入卖票环节
窗口2 进入卖票环节
窗口1 售卖的车票编号为: 3
窗口2 售卖的车票编号为: 2
窗口1 进入卖票环节
窗口2 进入卖票环节
窗口1 售卖的车票编号为: 1
窗口2 售卖的车票编号为: 0

结果说明:

从结果中我们看到窗口1在最后一次售卖中,卖出了编号为0的车票,实际上是不存在的。

出现这种问题的原因是当车票还剩1张的时候,2个窗口同时判断车票数量是否大于1,这时2个窗口就同时进入了售票扣减的代码,导致本来只能卖出1张的车票被2个窗口各自卖出了1张,从而产生了不存在的车票。

在程序里产生这种问题一般都是因为时间片的切换导致的,当一个线程进入操作共享资源的代码块时,时间片用完,另一个线程也通过判断进入了同一个代码块,导致第二个线程在操作共享资源时,没有重新进行判断。也就是说线程对共享资源的操作时不完整的,中间有可能被其他线程对资源进行修改。

单例模式的线程安全问题

◆ 懒汉式存在线程安全问题

这种写法起到了延迟加载的效果,但是只能在单线程下使用。如果在多线程下,一个线程进入了判断语句块,还没来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例,所以在多线程环境下不可使用这种方式。

 public class Singleton {
private static Singleton singleton; private Singleton() {} public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}

为了解决线程安全问题,我们可以使用synchronized关键字来修饰获取线程的公有方法,但是这么做会导致每次都要进入到同步方法里判断一下,方法进行同步效率太低。

 public class Singleton {
private static Singleton singleton; private Singleton() {} public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}

为了不需要每次都进行同步,可以使用双重检查,只需要在创建的时候进入同步方法,以后只要判断已经存在实例就直接返回实例,不需要再次进入同步方法。

 public class Singleton {
private static volatile Singleton singleton; private Singleton() {} public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}

除了使用同步机制保证线程安全之外,还可以使用静态内部类来保证线程安全。

这种方式跟饿汉式方式采用的机制类似,但又有不同。两者都是采用了类装载的机制来保证初始化实例时只有一个线程。不同的地方在饿汉式方式是只要Singleton类被装载就会实例化,没有延迟加载的作用,而静态内部类方式在Singleton类被装载时并不会立即实例化,而是在需要实例化时,调用getInstance方法,才会装载SingletonInstance类,从而完成Singleton的实例化。

类的静态属性只会在第一次加载类的时候初始化,所以在这里,JVM帮助我们保证了线程的安全性,在类进行初始化时,别的线程是无法进入的。

 public class Singleton {
private Singleton() {} private static class SingletonInstance {
private static final Singleton INSTANCE = new Singleton();
} public static Singleton getInstance() {
return SingletonInstance.INSTANCE;
}
}

◆ 饿汉式不存在线程安全问题

饿汉式的写法比较简单,就是在类装载的时候就完成实例化,避免了线程同步问题。

但这样会导致在类加载时就进行了实例化,没有做到延迟加载,如果这个实例没有被用到,会造成内存浪费。

 public class Singleton {
private final static Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() {
return INSTANCE;
}
}

产生并发问题的原因

多个线程操作共享的数据。

一个线程在操作共享数据时,其他线程也操作了共享数据。

使用volatile关键字

可见性

要想理解volatile关键字,得先了解下JAVA的内存模型:

每个线程都有一个自己的本地内存空间,线程执行时,先把变量从主内存读取到线程自己的本地内存空间,然后再对该变量进行操作。

对该变量操作完后,在某个时间再把变量刷新回主内存。

代码如下:

 public class Demo {
public static void main(String[] args) {
try {
DemoThread thread = new DemoThread();
thread.start();
Thread.sleep(100);
thread.setRunning(false);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} class DemoThread extends Thread {
private boolean isRunning = true; public void setRunning(boolean isRunning) {
this.isRunning = isRunning;
} @Override
public void run() {
System.out.println("进入方法");
while (isRunning) {
}
System.out.println("执行完毕");
}
}

运行结果如下:

 进入方法

结果说明:

线程一直在运行,并没有因为调用了setRunning()方法就停止了运行。

现在有两个线程,一个是main线程,另一个是RunThread。它们都试图修改isRunning变量。按照JVM内存模型,main线程将isRunning读取到本地线程内存空间,修改后,再刷新回主内存。

而在JVM设置成-server模式运行程序时,线程会一直在私有堆栈中读取isRunning变量。因此,RunThread线程无法读到main线程改变的isRunning变量,从而出现了死循环,导致RunThread无法终止。

解决办法就是在isRunning变量上加上volatile关键字修饰,它强制线程从主内存中取volatile修饰的变量。

代码如下:

 private volatile boolean isRunning = true;

运行结果如下:

1 进入方法
2 执行完毕

有序性

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。但是重排序也需要遵守一定规则:

1)重排序操作不会对存在数据依赖关系的操作进行重排序。

比如: a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。

比如: a=1;b=2;c=a+b; 这三个操作,第一步 a=1; 和第二步 b=2; 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b; 这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。

重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了。

但是运行代码并不能找到支持指令重排序的结果,所以这个地方以后还需要补充。

代码如下:

 public class Demo {
private int count = 1;
private boolean flag = false; public void write() {
count = 2;
flag = true;
} public void read() {
if (flag) {
System.out.print(count);
}
} public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
Demo demo = new Demo();
Thread write = new Thread(() -> {
demo.write();
});
Thread read = new Thread(() -> {
demo.read();
});
write.start();
read.start();
}
}
}

预测结果说明:

控制台打印的数据中应该有1出现,但实际情况却只以后2,这个并不能看出程序作了重排序。

预测有1出现的原因是,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而在write()方法中由于第一步 count = 2; 和第二步 flag = true; 不存在数据依赖关系,有可能会被重排序。。

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

原子性

所谓原子性,就是某系列的操作步骤要么全部执行,要么都不执行。

volatile只能保证对单次读/写的原子性,不能保证复合类操作的原子性。

代码如下:

 public class Demo {
public static void main(String[] args) {
DemoThread demoThread = new DemoThread();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(demoThread);
threads[i].start();
}
try {
Thread.sleep(1000);
System.out.println(demoThread.count);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
} class DemoThread extends Thread {
public volatile int count = 0; @Override
public void run() {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
add();
} private void add() {
for (int i = 0; i < 100; i++) {
count++;
}
}
}

运行结果如下:

1 986

结果说明:

在多线程环境下,有可能一个线程将count读取到本地内存中,此时其他线程可能已经将count增大了很多,线程依然对过期的count进行自加,重新写到主存中,最终导致了count的结果不合预期,而是小于1000。

如果想要在复合类的操作中保证原子性,可用使用synchronized关键字来实现,还可以通过Java并发包中的循环CAS的方式来保证。

使用synchronized关键字

synchronized是Java中解决并发问题的一种最常用的方法,也是最简单的一种方法。

synchronized的作用有三个:

◆ 确保线程互斥的访问同步代码。

◆ 保证共享变量的修改能够及时可见。

◆ 有效解决重排序问题。

从语法上讲,synchronized总共有三种用法:

◆ 修饰普通方法。

◆ 修饰静态方法。

◆ 修饰代码块。

接下来我就通过几个例子程序来说明一下这三种使用方式。

使用synchronized的同步代码块

使用synchronized关键字修饰的代码块将对共享资源的操作封装起来,当有一个线程运行代码块时,其他线程只能等待,从而避免共享资源被其他线程修改。

要求多个线程同步使用的锁都必须是同一个才能保证同步,常用的是使用一个Object对象,或者使用this,或者使用类的class对象。

代码如下:

 public class Demo {
public static void main(String[] args) {
DemoThread dt = new DemoThread();
Thread t1 = new Thread(dt, "窗口1");
Thread t2 = new Thread(dt, "窗口2");
t1.start();
t2.start();
}
} class DemoThread implements Runnable {
private int ticket = 3; @Override
public void run() {
while (ticket > 0) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (DemoThread.class) {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + " 进入卖票环节 ");
System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
}
}
}
}
}

运行结果如下:

 窗口1 进入卖票环节
窗口1 售卖的车票编号为: 3
窗口2 进入卖票环节
窗口2 售卖的车票编号为: 2
窗口1 进入卖票环节
窗口1 售卖的车票编号为: 1

结果说明:

线程在进入卖票的代码块之前,先看一下当前是否由其他线程在执行代码块,如果有其他线程在执行代码块则会等待,直到其他线程执行完之后才能进入代码块,从而保证了线程并发的安全问题。

使用synchronized的普通同步方法

将操作共享资源的代码封装为方法,添加synchronized关键字修饰,这个方法就是同步方法,使用的锁是this对象。

代码如下:

 public class Demo {
public static void main(String[] args) {
DemoThread dt = new DemoThread();
Thread t1 = new Thread(dt, "窗口1");
Thread t2 = new Thread(dt, "窗口2");
t1.start();
t2.start();
}
} class DemoThread implements Runnable {
private int ticket = 3; @Override
public void run() {
while (ticket > 0) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
sale();
}
} public synchronized void sale() {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + " 进入卖票环节 ");
System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
}
}
}

运行结果如下:

 窗口1 进入卖票环节
窗口1 售卖的车票编号为: 3
窗口2 进入卖票环节
窗口2 售卖的车票编号为: 2
窗口2 进入卖票环节
窗口2 售卖的车票编号为: 1

结果说明:

在每次调用sale()方法售票的时候,程序会将实例对象this作为锁,保证一个时间只能有一个线程在操作共享资源。

使用synchronized的静态同步方法

如果该方法是静态方法,因为静态方法优先于类的实例化,所以静态方法是不能持有this的,静态同步方法的琐是类的class对象。

代码如下:

 public class Demo {
public static void main(String[] args) {
DemoThread dt = new DemoThread();
Thread t1 = new Thread(dt, "窗口1");
Thread t2 = new Thread(dt, "窗口2");
t1.start();
t2.start();
}
} class DemoThread implements Runnable {
private static int ticket = 3; @Override
public void run() {
while (ticket > 0) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
sale();
}
} public static synchronized void sale() {
if (ticket > 0) {
System.out.println(Thread.currentThread().getName() + " 进入卖票环节 ");
System.out.println(Thread.currentThread().getName() + " 售卖的车票编号为: " + ticket--);
}
}
}

运行结果如下:

 窗口2 进入卖票环节
窗口2 售卖的车票编号为: 3
窗口1 进入卖票环节
窗口1 售卖的车票编号为: 2
窗口2 进入卖票环节
窗口2 售卖的车票编号为: 1

结果说明:

使用静态同步方法除了需要注意共享资源也要用static修饰外,其他的和普通同步方法是一样的。

synchronized关键字和volatile关键字的区别

含义

volatile主要用在多个线程感知实例变量被更改了场合,从而使得各个线程获得最新的值。它强制线程每次从主内存中讲到变量,而不是从线程的私有内存中读取变量,从而保证了数据的可见性。

synchronized主要通过对象锁控制线程对共享数据的访问,持有相同对象锁的线程只能等其他持有同一个对象锁的线程执行完毕之后,才能持有这个对象锁访问和处理共享数据。

比较

◆ 量级比较

volatile轻量级,只能修饰变量。

synchronized重量级,还可修饰方法。

◆ 可见性和原子性

volatile只能保证数据的可见性,不能用来同步,因为多个线程并发访问volatile修饰的变量不会阻塞。

synchronized不仅保证可见性,而且还保证原子性,因为,只有获得了锁的线程才能进入临界区,从而保证临界区中的所有语句都全部执行。多个线程争抢synchronized锁对象时,会出现阻塞。

同步使用总结

要使用synchronized,必须要有两个以上的线程。单线程使用没有意义,还会使效率降低。

要使用synchronized,线程之间需要发生同步,不需要同步的没必要使用synchronized,例如只读数据。

使用synchronized的缺点是效率非常低,因为加锁、释放锁和释放锁后争抢CPU执行权的操作都很耗费资源。