Java多线程4:synchronized锁机制
脏读
一个常见的概念。在多线程中,难免会出现在多个线程中对同一个对象的实例变量进行并发访问的情况,如果不做正确的同步处理,那么产生的后果就是"脏读",也就是取到的数据其实是被更改过的。
多线程线程安全问题示例
看一段代码:
public class ThreadDomain13 { private int num = 0; public void addNum(String userName) { try { if ("a".equals(userName)) { num = 100; System.out.println("a set over!"); Thread.sleep(2000); } else { num = 200; System.out.println("b set over!"); } System.out.println(userName + " num = " + num); } catch (InterruptedException e) { e.printStackTrace(); } } }
写两个线程分别去add字符串"a"和字符串"b":
public class MyThread13_0 extends Thread { private ThreadDomain13 td; public MyThread13_0(ThreadDomain13 td) { this.td = td; } public void run() { td.addNum("a"); } }
public class MyThread13_1 extends Thread { private ThreadDomain13 td; public MyThread13_1(ThreadDomain13 td) { this.td = td; } public void run() { td.addNum("b"); } }
写一个主函数分别运行这两个线程:
public static void main(String[] args) { ThreadDomain13 td = new ThreadDomain13(); MyThread13_0 mt0 = new MyThread13_0(td); MyThread13_1 mt1 = new MyThread13_1(td); mt0.start(); mt1.start(); }
看一下运行结果:
a set over! b set over! b num = 200 a num = 200
按照正常来看应该打印"a num = 100"和"b num = 200"才对,现在却打印了"b num = 200"和"a num = 200",这就是线程安全问题。我们可以想一下是怎么会有线程安全的问题的:
- mt0先运行,给num赋值100,然后打印出"a set over!",开始睡觉
- mt0在睡觉的时候,mt1运行了,给num赋值200,然后打印出"b set over!",然后打印"b num = 200"
- mt1睡完觉了,由于mt0的num和mt1的num是同一个num,所以mt1把num改为了200了,mt0也没办法,对于它来说,num只能是100,mt0继续运行代码,打印出"a num = 200"
分析了产生问题的原因,解决就很简单了,给addNum(String userName)方法加同步即可:
public class ThreadDomain13 { private int num = 0; public synchronized void addNum(String userName) { try { if ("a".equals(userName)) { num = 100; System.out.println("a set over!"); Thread.sleep(2000); } else { num = 200; System.out.println("b set over!"); } System.out.println(userName + " num = " + num); } catch (InterruptedException e) { e.printStackTrace(); } } }
看一下运行结果:
a set over! a num = 100 b set over! b num = 200
多个对象多个锁
在同步的情况下,把main函数内的代码改一下:
public static void main(String[] args) { ThreadDomain13 td0 = new ThreadDomain13(); ThreadDomain13 td1 = new ThreadDomain13(); MyThread13_0 mt0 = new MyThread13_0(td0); MyThread13_1 mt1 = new MyThread13_1(td1); mt0.start(); mt1.start(); }
看一下运行结果:
a set over! b set over! b num = 200 a num = 100
打印结果的方式变了,打印的顺序是交叉的,这又是为什么呢?
这里有一个重要的概念。关键字synchronized取得的锁都是对象锁,而不是把一段代码或方法(函数)当作锁,哪个线程先执行带synchronized关键字的方法,哪个线程就持有该方法所属对象的锁,其他线程都只能呈等待状态。但是这有个前提:既然锁叫做对象锁,那么势必和对象相关,所以多个线程访问的必须是同一个对象。
如果多个线程访问的是多个对象,那么Java虚拟机就会创建多个锁,就像上面的例子一样,创建了两个ThreadDomain13对象,就产生了2个锁。既然两个线程持有的是不同的锁,自然不会受到"等待释放锁"这一行为的制约,可以分别运行addNum(String userName)中的代码。
synchronized方法与锁对象
上面我们认识了对象锁,对象锁这个概念,比较抽象,确实不太好理解,看一个例子,在一个实体类中定义一个同步方法和一个非同步方法:
public class ThreadDomain14_0 { public synchronized void methodA() { try { System.out.println("Begin methodA, threadName = " + Thread.currentThread().getName()); Thread.sleep(5000); System.out.println("End methodA, threadName = " + Thread.currentThread().getName() + ", end Time = " + System.currentTimeMillis()); } catch (InterruptedException e) { e.printStackTrace(); } } public void methodB() { try { System.out.println("Begin methodB, threadName = " + Thread.currentThread().getName() + ", begin time = " + System.currentTimeMillis()); Thread.sleep(5000); System.out.println("End methodB, threadName = " + Thread.currentThread().getName()); } catch (InterruptedException e) { e.printStackTrace(); } } }
一个线程调用其同步方法,一个线程调用其非同步方法:
public class MyThread14_0 extends Thread { private ThreadDomain14_0 td; public MyThread14_0(ThreadDomain14_0 td) { this.td = td; } public void run() { td.methodA(); } }
public class MyThread14_1 extends Thread { private ThreadDomain14_0 td; public MyThread14_1(ThreadDomain14_0 td) { this.td = td; } public void run() { td.methodB(); } }
写一个main函数去调用这两个线程:
public static void main(String[] args) { ThreadDomain14_0 td = new ThreadDomain14_0(); MyThread14_0 mt0 = new MyThread14_0(td); mt0.setName("A"); MyThread14_1 mt1 = new MyThread14_1(td); mt1.setName("B"); mt0.start(); mt1.start(); }
看一下运行效果:
Begin methodA, threadName = A Begin methodB, threadName = B, begin time = 1443697780869 End methodB, threadName = B End methodA, threadName = A, end Time = 1443697785871
从结果看到,第一个线程调用了实体类的methodA()方法,第二个线程完全可以调用实体类的methodB()方法。但是我们把methodB()方法改为同步就不一样了,就不列修改之后的代码了,看一下运行结果:
Begin methodA, threadName = A End methodA, threadName = A, end Time = 1443697913156 Begin methodB, threadName = B, begin time = 1443697913156 End methodB, threadName = B
从这个例子我们得出两个重要结论:
- A线程持有Object对象的Lock锁,B线程可以以异步方式调用Object对象中的非synchronized类型的方法
- A线程持有Object对象的Lock锁,B线程如果在这时调用Object对象中的synchronized类型的方法则需要等待,也就是同步
synchronized锁重入
关键字synchronized拥有锁重入的功能。所谓锁重入的意思就是:当一个线程得到一个对象锁后,再次请求此对象锁时可以再次得到该对象的锁。看一个例子:
public class ThreadDomain16 { public synchronized void print1() { System.out.println("ThreadDomain16.print1()"); print2(); } public synchronized void print2() { System.out.println("ThreadDomain16.print2()"); print3(); } public synchronized void print3() { System.out.println("ThreadDomain16.print3()"); } }
public class MyThread16 extends Thread { public void run() { ThreadDomain16 td = new ThreadDomain16(); td.print1(); } }
public static void main(String[] args) { MyThread16 mt = new MyThread16(); mt.start(); }
看一下运行结果:
ThreadDomain16.print1()
ThreadDomain16.print2()
ThreadDomain16.print3()
看到可以直接调用ThreadDomain16中的打印语句,这证明了对象可以再次获取自己的内部锁。这种锁重入的机制,也支持在父子类继承的环境中。
异常自动释放锁
最后一个知识点是异常。当一个线程执行的代码出现异常时,其所持有的锁会自动释放。模拟的是把一个long型数作为除数,从MAX_VALUE开始递减,直至减为0,从而产生ArithmeticException。看一下例子:
public class ThreadDomain17 { public synchronized void testMethod() { try { System.out.println("Enter ThreadDomain17.testMethod, currentThread = " + Thread.currentThread().getName()); long l = Integer.MAX_VALUE; while (true) { long lo = 2 / l; l--; } } catch (Exception e) { e.printStackTrace(); } } }
public class MyThread17 extends Thread { private ThreadDomain17 td; public MyThread17(ThreadDomain17 td) { this.td = td; } public void run() { td.testMethod(); } }
public static void main(String[] args) { ThreadDomain17 td = new ThreadDomain17(); MyThread17 mt0 = new MyThread17(td); MyThread17 mt1 = new MyThread17(td); mt0.start(); mt1.start(); }
看一下运行结果:
Enter ThreadDomain17.testMethod, currentThread = Thread-0 Enter ThreadDomain17.testMethod, currentThread = Thread-1 java.lang.ArithmeticException: / by zero at com.xrq.example.e17.ThreadDomain17.testMethod(ThreadDomain17.java:14) at com.xrq.example.e17.MyThread17.run(MyThread17.java:14) java.lang.ArithmeticException: / by zero at com.xrq.example.e17.ThreadDomain17.testMethod(ThreadDomain17.java:14) at com.xrq.example.e17.MyThread17.run(MyThread17.java:14)
因为打印结果是静态的,所以不是很明显。在l--前一句加上Thread.sleep(1)结论会更明显,第一句打出来之后,整个程序都停住了,直到Thread-0抛出异常后,Thread-1才可以运行,这也证明了我们的结论。
后记
文章里面的这些个结论,记一下都是很快的,但是是否记一下就好了?我认为记住这些结论一点都不重要,重要的应该是学习如何通过代码去验证这些结论。因为只有知道了如何通过代码去验证结论,才可以说真正对于synchronized关键字的各种细节有了感性、有了深入的理解,以后碰到其他synchronized的场景就可以以自己的理解去正确分析问题。
我不能保证写的每个地方都是对的,但是至少能保证不复制、不黏贴,保证每一句话、每一行代码都经过了认真的推敲、仔细的斟酌。每一篇文章的背后,希望都能看到自己对于技术、对于生活的态度。
我相信乔布斯说的,只有那些疯狂到认为自己可以改变世界的人才能真正地改变世界。面对压力,我可以挑灯夜战、不眠不休;面对困难,我愿意迎难而上、永不退缩。
其实我想说的是,我只是一个程序员,这就是我现在纯粹人生的全部。
==================================================================================