Java多线程同步问题:一个小Demo完全搞懂

时间:2024-01-23 15:40:23

版权声明:本文出自汪磊的博客,转载请务必注明出处。

Java线程系列文章只是自己知识的总结梳理,都是最基础的玩意,已经掌握熟练的可以绕过。

一、一个简单的Demo引发的血案

关于线程同步问题我们从一个简单的Demo现象说起。Demo特别简单就是开启两个线程打印字符串信息。

OutPutStr类源码:

1 public class OutPutStr {
2 
3     public void out(String str) {
4         for (int i = 0; i < str.length(); i++) {
5             System.out.print(str.charAt(i));
6        }
7         System.out.println();
8     }
9 }

很简单吧,就是一个方法供外界调用,调用的时候传进来一个字符串,方法逐个取出字符串的字符并打印到控制台。

接下来,我们看main方法中逻辑:

 1 public static void main(String[] args) {
 2         //
 3         final OutPutStr o = new OutPutStr();
 4         new Thread(new Runnable() {
 5 
 6             @Override
 7             public void run() {
 8                 //
 9                 while(true){
10                     o.out("111111111111");
11                 }
12             }
13         }).start();
14         new Thread(new Runnable() {
15 
16             @Override
17             public void run() {
18                 //
19                 while(true){
20                     o.out("222222222222");
21                 }
22             }
23         }).start();
24 }

也很简单,就是开启两个线程分别调用OutPutStr中out方法不停打印字符串信息,运行程序打印信息如下:

1 222222222222
2 222222222222
3 22222222222111111111
4 2
5 111111111111
6 111111111111
7 1111222222222211111111
8 111111111111

咦?和我们想的不一样啊,怎么还会打印出22222222222111111111这样子的信息,这是怎么回事呢?

二、原因解析

我们知道线程的执行是CPU随机调度的,比如我们开启10个线程,这10个线程并不是同时执行的,而是CPU快速的在这10个线程之间切换执行,由于切换速度极快使我们感觉同时执行罢了。发生上面问题的本质就是CPU对线程执行的随机调度,比如A线程此时正在打印信息还没打印完毕此时CPU切换到B线程执行了,B线程执行完了又切换回A线程执行就会导致上面现象发生。

线程同步问题往往发生在多个线程调用同一方法或者操作同一变量,但是我们要知道其本质就是CPU对线程的随机调度,CPU无法保证一个线程执行完其逻辑才去调用另一个线程执行。

三、同步方法解决上述问题

既然知道了问题发生的原因,记下来我们就要想办法解决问题啊,解决的思路就是保证一个线程在调用out方法的时候如果没执行完那么另一个不能执行此方法,换句话说就是只能等待别的线程执行完毕才能执行。

针对线程同步问题java早就有解决方法了,最简单的就是给方法加上synchronized关键字,如下:

1 public synchronized void out(String str) {
2         for (int i = 0; i < str.length(); i++) {
3             System.out.print(str.charAt(i));
4         }
5         System.out.println();
6 }

这是什么意思呢?加上synchronized关键字后,比如A线程执行out方法就相当于拿到了一把锁,只有获取这个锁才能执行此方法,如果在A线程执行out方法过程中B线程也想插一脚进来执行out方法,对不起此时这是不能够的,因为此时锁在A线程手里,B线程无权拿到这把锁,只有等到A线程执行完后放弃锁,B线程才能拿到锁执行out方法。

为out方法加上synchronized后其就变成了同步方法,普通同步方法的锁是this,也就是当前对象,比如demo中,外部要想调用out方法就必须创建OutPutStr类实例对象o,此时out同步方法的锁就是这个o。

四、同步代码块解决上述问题

我们也可以利用同步代码块解决上述问题,修改out方法如下:

1 public void out(String str) {
2         synchronized (this) {
3             for (int i = 0; i < str.length(); i++) {
4                 System.out.print(str.charAt(i));
5             }
6             System.out.println();
7         }
8 }

同步代码块写法:synchronized(obj){},其中obj为锁对象,此处我们传入this,同样方法的锁也为当前对象,如果此处我们传入str,那么这里的锁就是str对象了。

为了说明不同锁带来的影响我们修改OutPutStr代码如下:

 1 public class OutPutStr {
 2 
 3     public synchronized void out(String str) {
 4         for (int i = 0; i < str.length(); i++) {
 5             System.out.print(str.charAt(i));
 6         }
 7         System.out.println();
 8     }
 9     
10     public void out1(String str) {
11         
12         synchronized (str) {
13             for (int i = 0; i < str.length(); i++) {
14                 System.out.print(str.charAt(i));
15             }
16             System.out.println();
17         }
18     }
19 }

很简单我们就是加入了一个out1方法,out方法用同步函数保证同步,out1用同步代码块保证代码块,但是锁我们用的是str。

main代码:

 1 public static void main(String[] args) {
 2         //
 3         final OutPutStr o = new OutPutStr();
 4         new Thread(new Runnable() {
 5 
 6             @Override
 7             public void run() {
 8                 //
 9                 while(true){
10                     o.out("111111111111");
11                 }
12             }
13         }).start();
14         new Thread(new Runnable() {
15 
16             @Override
17             public void run() {
18                 //
19                 while(true){
20                     o.out1("222222222222");
21                 }
22             }
23         }).start();
24     }

也没什么,就是其中一个线程调用out方法,另一个调用out1方法,运行程序:

111111111111222
222222222222

111111111111222222222222
222222222222

看到了吧,打印信息又出问题了,就是因为out与out1方法的锁不一样导致的,线程A调用out方法拿到this这把锁,线程B调用out1拿到str这把锁,二者互不影响,解决办法也很简单,修改out1方法如下即可:

1 public void out1(String str) {
2         
3         synchronized (this) {
4             for (int i = 0; i < str.length(); i++) {
5                 System.out.print(str.charAt(i));
6             }
7             System.out.println();
8         }
9 }

五、静态函数的同步问题

我们继续修改OutPutStr类,加入out2方法:

 1 public class OutPutStr {
 2 
 3     public synchronized void out(String str) {
 4         for (int i = 0; i < str.length(); i++) {
 5             System.out.print(str.charAt(i));
 6         }
 7         System.out.println();
 8     }
 9 
10     public void out1(String str) {
11 
12         synchronized (this) {
13             for (int i = 0; i < str.length(); i++) {
14                 System.out.print(str.charAt(i));
15             }
16             System.out.println();
17         }
18     }
19 
20     public synchronized static void out2(String str) {
21 
22         for (int i = 0; i < str.length(); i++) {
23             System.out.print(str.charAt(i));
24         }
25         System.out.println();
26     }
27 }

main中两个子线程分别调用out1,ou2打印信息,运行程序打印信息如下;

1 222222222222
2 222222222222
3 222222222111111111111
4 111111111111

咦?又出错了,out2与out方法唯一不同就是out2就是静态方法啊,不是说同步方法锁是this吗,是啊,没错,但是静态方法没有对应类的实例对象依然可以调用,那其锁是谁呢?显然静态方法锁不是this,这里就直说了,是类的字节码对象,类的字节码对象是优先于类实例对象存在的。

将ou1方法改为如下:

1 public void out1(String str) {
2 
3         synchronized (OutPutStr.class) {
4             for (int i = 0; i < str.length(); i++) {
5                 System.out.print(str.charAt(i));
6             }
7             System.out.println();
8         }
9 }

再次运行程序,就会发现信息能正常打印了。

六、synchronized同步方式总结

到此我们就该小小的总结一下了,普通同步函数的锁是this,当前类实例对象,同步代码块锁可以自己定义,静态同步函数的锁是类的字节码文件。总结完毕,就是这么简单。说了一大堆理解这一句就够了。

七、JDK1.5中Lock锁机制解决线程同步

大家是不是觉得上面说的锁这个玩意咋这么抽象,看不见,摸不着的。从JDK1.5起我们就可以根据需要显性的获取锁以及释放锁了,这样也更加符合面向对象原则。

Lock接口的实现子类之一ReentrantLock,翻译过来就是重入锁,就是支持重新进入的锁,该锁能够支持一个线程对资源的重复加锁,也就是说在调用lock()方法时,已经获取到锁的线程,能够再次调用lock()方法获取锁而不被阻塞,同时还支持获取锁的公平性和非公平性,所谓公平性就是多个线程发起lock()请求,先发起的线程优先获取执行权,非公平性就是获取锁与是否优先发起lock()操作无关。默认情况下是不公平的锁,为什么要这样设计呢?现实生活中我们都希望公平的啊?我们想一下,现实生活中要保证公平就必须额外开销,比如地铁站保证有序公平进站就必须配备额外人员维持秩序,程序中也是一样保证公平就必须需要额外开销,这样性能就下降了,所以公平与性能是有一定矛盾的,除非公平策略对你的程序很重要,比如必须按照顺序执行线程,否则还是使用不公平锁为好。

接下来我们修改OutPutStr类,添加out3方法:

 1 //true表示公平锁,false非公平锁
 2     private Lock lock = new ReentrantLock();
 3     
 4     public void out3(String str) {
 5         
 6         lock.lock();//如果有其它线程已经获取锁,那么当前线程在此等待直到其它线程释放锁。
 7         try {
 8             for (int i = 0; i < str.length(); i++) {
 9                 System.out.print(str.charAt(i));
10             }
11             System.out.println();
12         } finally {
13             lock.unlock();//释放锁资源,之所以加入try{}finally{}代码块,
14             //是为了保证锁资源的释放,如果代码发生异常也可以保证锁资源的释放,
15             //否则其它线程无法拿到锁资源执行业务逻辑,永远处于等待状态。
16         }
17     }

关键注释都在代码中有所体现了,使用起来也很简单。

八、Lock与synchronized同步方式优缺点

Lock 的锁定是通过代码实现的,而 synchronized 是在 JVM 层面上实现的(所有对象都自动含有单一的锁。JVM负责跟踪对象被加锁的次数。如果一个对象被解锁,其计数变为0。在线程第一次给对象加锁的时候,计数变为1。每当这个相同的线程在此对象上获得锁时,计数会递增。只有首先获得锁的线程才能继续获取该对象上的多个锁。每当线程离开一个synchronized方法,计数递减,当计数为0的时候,锁被完全释放,此时别的线程就可以使用此资源)。

synchronized 在锁定时如果方法块抛出异常,JVM 会自动将锁释放掉,不会因为出了异常没有释放锁造成线程死锁。但是 Lock 的话就享受不到 JVM 带来自动的功能,出现异常时必须在 finally 将锁释放掉,否则将会引起死锁。

在资源竞争不是很激烈的情况下,偶尔会有同步的情形下,synchronized是很合适的。原因在于,编译程序通常会尽可能的进行优化synchronize,另外可读性非常好。在资源竞争激烈情况下,Lock同步机制性能会更好一些。

 

关于线程同步问题到这里就结束了,java多线程文章只是本人工作以来的一次梳理,都比较基础,但是却很重要的,最近招人面试的最大体会就是都喜欢那些所谓时髦的技术一问基础说的乱七八糟,浪费彼此的时间。好啦,吐槽了几句,本文到此为止,很基础的玩意,希望对你有用。

声明:文章将会陆续搬迁到个人公众号,以后文章也会第一时间发布到个人公众号,及时获取文章内容请关注公众号