1. 多线程引发的问题:
1.1 脏读:
一个常见的概念。在多线程中,难免会出现在多个线程中对同一个对象的实例变量进行并发访问的情况,如果不做正确的同步处理,那么产生的后果就是"脏读",也就是取到的数据其实是被更改过的。
代码示例:
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();
}
}
}
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"
1.2 指令交错问题(脏读):
普通的i++在我们看来是一个操作,在JVM字节码中是多行命令
例:
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter++;
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 5000; i++) {
counter--;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
log.debug("{}", counter);
}
结果:
16:11:33.821 [main] DEBUG testplus - 753
i++的字节码(i是静态变量):
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
iadd // 自增
putstatic i // 将修改后的值存入静态变量i
i–的字节码(i是静态变量):
getstatic i // 获取静态变量i的值
iconst_1 // 准备常量1
isub // 自增
putstatic i // 将修改后的值存入静态变量i
而java的内存模型如下,完成静态变量的自增、自减操作需要在内存和工作内存中进行数据交换
如果上述操作是顺序执行就不会有问题:
并行出现异常的情况:
2. Synchronized修饰方法:
关于上述问题,我们只需要给addNum加锁就能解决:
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
2.1 修饰方法的注意点:
2.1.1 synchronized用法限制
- synchronized关键字不能继承。
虽然可以使用synchronized来定义方法,但synchronized并不属于方法定义的一部分,因此,synchronized关键字不能被继承。如果在父类中的某个方法使用了synchronized关键字,而在子类中覆盖了这个方法,在子类中的这个方法默认情况下并不是同步的,而必须显式地在子类的这个方法中加上synchronized关键字才可以。当然,还可以在子类方法中调用父类中相应的方法,这样虽然子类中的方法不是同步的,但子类调用了父类的同步方法,因此,子类的方法也就相当于同步了 - 在定义接口方法时不能使用synchronized关键字。
- 构造方法不能使用synchronized关键字,但可以使用synchronized代码块来进行同步。
2.1.2 synchronized锁住的是对象而不是方法:
在同步的情况下,将上述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)中的代码。
2.1.3 非同步方法的使用问题:
在类中定义一个同步方法和一个非同步方法:
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() + ");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public void methodB() {
try {
System.out.println("Begin methodB, threadName = " +
Thread.currentThread().getName() + ");
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();
}
}
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
End methodB, threadName = B
End methodA, threadName = A
从结果看到,第一个线程调用了实体类的methodA()方法,第二个线程完全可以调用实体类的methodB()方法。但是我们把methodB()方法改为同步就不一样了,就不列修改之后的代码了,看一下运行结果:
Begin methodA, threadName = A
End methodA, threadName = A
Begin methodB, threadName = B
End methodB, threadName = B
从这个例子我们得出两个重要结论:
- A线程持有Object对象的Lock锁,B线程可以以异步方式调用Object对象中的非synchronized类型的方法
- A线程持有Object对象的Lock锁,B线程如果在这时调用Object对象中的synchronized类型的方法则需要等待,也就是同步
2.1.4 锁重入:
关键字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中的打印语句,这证明了对象可以再次获取自己的内部锁。这种锁重入的机制,也支持在父子类继承的环境中。
2.1.5 异常自动释放锁:
当一个线程执行的代码出现异常时,其所持有的锁会自动释放。
示例:
public class TestExceptionLock {
public synchronized void testMethod() {
try {
System.out.println("Enter TestExceptionLock.testMethod, currentThread = " +
Thread.currentThread().getName());
int l = 5;
while (true) {
int lo = 2 / l;
Thread.sleep(10);
l--;
}
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
TestExceptionLock td = new TestExceptionLock();
MyThread17 mt0 = new MyThread17(td);
MyThread17 mt1 = new MyThread17(td);
mt0.start();
mt1.start();
}
}
class MyThread17 extends Thread {
private TestExceptionLock td;
public MyThread17(TestExceptionLock td) {
this.td = td;
}
public void run() {
td.testMethod();
}
}
结果:
Enter TestExceptionLock.testMethod, currentThread = Thread-0
java.lang.ArithmeticException: / by zero
at com.yhx.juc.Thread.TestExceptionLock.testMethod(TestExceptionLock.java:15)
at com.yhx.juc.Thread.MyThread17.run(TestExceptionLock.java:41)
Enter TestExceptionLock.testMethod, currentThread = Thread-1
java.lang.ArithmeticException: / by zero
at com.yhx.juc.Thread.TestExceptionLock.testMethod(TestExceptionLock.java:15)
at com.yhx.juc.Thread.MyThread17.run(TestExceptionLock.java:41)
可以看到当第一个线程抛出异常后,第二个线程拿到了锁。
3. Synchronized修饰代码块:
修饰代码块和修饰方法有很多相似之处,这里不再做代码赘述。
3.1 只锁住加锁的部分:
- 当A线程访问对象的synchronized代码块的时候,B线程依然可以访问对象方法中其余非synchronized块的部分
- 当A线程进入对象的synchronized代码块的时候,B线程如果要访问这段synchronized块,那么访问将会被阻塞
所以如果有时候从性能角度考虑,我们可以不锁住整个方法,只锁住方法中的一部分。
3.2 两个synchronized块(方法)之间具有互斥性
synchronized块获得的是一个对象锁,换句话说,synchronized块锁定的是整个对象。
如果线程1访问了一个对象中方法A的synchronized块,那么线程2对同一对象中方法B的synchronized块的访问将被阻塞。
以此类推:synchronized块和synchronized方法之间同样具体有互斥性。
4. 任意对象作为对象监视器:
前面都使用synchronized(this)的格式来同步代码块,其实Java还支持对"任意对象作为对象监视器来实现同步的功能。这个"任意对象"大多数是实例变量及方法的参数,使用格式为synchronized(非this对象)。看一下将任意对象作为对象监视器的使用例子:
public class ThreadDomain21 {
private String userNameParam;
private String passwordParam;
private String anyString = new String();
public void setUserNamePassword(String userName, String password) {
try {
synchronized (anyString) {
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在 " + System.currentTimeMillis() + " 进入同步代码块");
userNameParam = userName;
Thread.sleep(3000);
passwordParam = password;
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在 " + System.currentTimeMillis() + " 离开同步代码块");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class MyThread21_0 extends Thread {
private ThreadDomain21 td;
public MyThread21_0(ThreadDomain21 td) {
this.td = td;
}
public void run() {
td.setUserNamePassword("A", "A");
}
}
public class MyThread21_1 extends Thread {
private ThreadDomain21 td;
public MyThread21_1(ThreadDomain21 td) {
this.td = td;
}
public void run() {
td.setUserNamePassword("B", "B");
}
}
public static void main(String[]args) {
ThreadDomain21 td=new ThreadDomain21();
MyThread21_0 mt0=new MyThread21_0(td);
MyThread21_1 mt1=new MyThread21_1(td);
mt0.start();
mt1.start();
}
看一下运行结果:
线程名称为:Thread-0在 1443855101706 进入同步代码块
线程名称为:Thread-0在 1443855104708 离开同步代码块
线程名称为:Thread-1在 1443855104708 进入同步代码块
线程名称为:Thread-1在 1443855107708 离开同步代码块
这个例子证明了:多个线程持有"对象监视器"为同一个对象的前提下,同一时间只能有一个线程可以执行synchronized(非this对象x)代码块中的代码。
锁非this对象具有一定的优点:如果在一个类中有很多synchronized方法,这时虽然能实现同步,但会受到阻塞,从而影响效率。但如果同步代码块锁的是非this对象,则synchronized(非this对象x)代码块中的程序与同步方法是异步的,不与其他锁this同步方法争抢this锁,大大提高了运行效率。
注意一下"private String anyString = new String();
"这句话,现在它是一个全局对象,因此监视的是同一个对象。如果移到try里面,那么对象的监视器就不是同一个了,调用的时候自然是异步调用,可以自己试一下。
最后提一点,synchronized(非this对象x),这个对象如果是实例变量的话,指的是对象的引用,只要对象的引用不变,即使改变了对象的属性,运行结果依然是同步的。
synchronized(非this对象x)格式的写法是将x对象本身作为对象监视器,有三个结论得出:
- 当多个线程同时执行synchronized(x){}同步代码块时呈同步效果
- 当其他线程执行x对象中的synchronized同步方法时呈同步效果
- 当其他线程执行x对象方法中的synchronized(this)代码块时也呈同步效果
5. Synchronized修饰类方法(静态方法)
synchronized还可以应用在静态方法上,如果这么写,则代表的是对当前.java文件对应的Class类加锁。
即:
class Test {
synchronized static void printA(){}
}
等价于:
class Test {
static void printA(){
synchronized (Test.class) {}
}
}
也可以这么写:synchronized(ClassName.class) {。。。}
看一下例子,注意一下printC()并不是一个静态方法:
public class ThreadDomain25 {
public synchronized static void printA() {
try {
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "进入printA()方法");
Thread.sleep(3000);
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "离开printA()方法");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized static void printB() {
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "进入printB()方法");
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "离开printB()方法");
}
public synchronized void printC() {
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "进入printC()方法");
System.out.println("线程名称为:" + Thread.currentThread().getName() +
"在" + System.currentTimeMillis() + "离开printC()方法");
}
}
public class MyThread25_0 extends Thread {
public void run() {
ThreadDomain25.printA();
}
}
public class MyThread25_1 extends Thread {
public void run() {
ThreadDomain25.printB();
}
}
public class MyThread25_2 extends Thread {
private ThreadDomain25 td;
public MyThread25_2(ThreadDomain25 td) {
this.td = td;
}
public void run() {
td.printC();
}
}
public static void main(String[]args) {
ThreadDomain25 td=new ThreadDomain25();
MyThread25_0 mt0=new MyThread25_0();
MyThread25_1 mt1=new MyThread25_1();
MyThread25_2 mt2=new MyThread25_2(td);
mt0.start();
mt1.start();
mt2.start();
}
结果:
线程名称为:Thread-0在1443857019710进入printA()方法
线程名称为:Thread-2在1443857019710进入printC()方法
线程名称为:Thread-2在1443857019710离开printC()方法
线程名称为:Thread-0在1443857022710离开printA()方法
线程名称为:Thread-1在1443857022710进入printB()方法
线程名称为:Thread-1在1443857022710离开printB()方法
从运行结果来,对printC()方法的调用和对printA()方法、printB()方法的调用时异步的,这说明了静态同步方法和非静态同步方法持有的是不同的锁,前者是类锁,后者是对象锁。
类锁是对静态方法使用synchronized关键字后,无论是多线程访问单个对象还是多个对象的sychronized块,都是同步的。
所谓类锁,举个再具体的例子。假如一个类中有一个静态同步方法A,new出了两个类的实例B和实例C,线程D持有实例B,线程E持有实例C,只要线程D调用了A方法,那么线程E调用A方法必须等待线程D执行完A方法,尽管两个线程持有的是不同的对象。
6. Synchronized的7个应用:
6.1 应用1:
@Slf4j(topic = "lockTest")
class LockTest {
public static void main(String[] args) {
Number n1 = new Number();
new Thread(() -> {
log.debug("begin");
n1.a();
}).start();
new Thread(() -> {
log.debug("begin");
n1.b();
}).start();
}
}
@Slf4j(topic = "number")
class Number {
public synchronized void a() {
log.debug("1");
}
public synchronized void b() {
log.debug("2");
}
}
结果(12或者21):
16:54:48.728 [Thread-1] DEBUG lockTest - begin
16:54:48.732 [Thread-1] DEBUG number - 2
16:54:48.728 [Thread-0] DEBUG lockTest - begin
16:54:48.732 [Thread-0] DEBUG number - 1
6.2 应用2:
修改上述a()方法:
public synchronized void a() throws InterruptedException {
Thread.sleep(1000);
log.debug("1");
}
结果(1s后12或者2,1s后1,sleep并不释放锁):
16:56:37.727 [Thread-1] DEBUG lockTest - begin
16:56:37.732 [Thread-1] DEBUG number - 2
16:56:37.727 [Thread-0] DEBUG lockTest - begin
16:56:38.747 [Thread-0] DEBUG number - 1
6.3 应用3:
添加非同步方法c:
public void c() {
log.debug("3");
}
结果(3,1s后1,2;23/32,1s后1):
17:03:34.160 [Thread-2] DEBUG lockTest - begin
17:03:34.164 [Thread-2] DEBUG number - 3
17:03:34.160 [Thread-0] DEBUG lockTest - begin
17:03:34.160 [Thread-1] DEBUG lockTest - begin
17:03:35.173 [Thread-0] DEBUG number - 1
17:03:35.174 [Thread-1] DEBUG number - 2
6.4 应用4:
修改main方法:
Number n1 = new Number();
Number n2 = new Number();
new Thread(() -> {
log.debug("begin");
try {
n1.a();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
log.debug("begin");
n2.b();
}).start();
结果(锁住的不是一个对象,2一定会打印出来):
17:07:39.122 [Thread-0] DEBUG lockTest - begin
17:07:39.122 [Thread-1] DEBUG lockTest - begin
17:07:39.128 [Thread-1] DEBUG number - 2
17:07:40.132 [Thread-0] DEBUG number - 1
6.5 应用5:
回退应用4代码,修改方法a:
public static synchronized void a() throws InterruptedException {
Thread.sleep(1000);
log.debug("1");
}
结果(锁住的不是一个对象):
17:09:58.211 [Thread-0] DEBUG lockTest - begin
17:09:58.211 [Thread-1] DEBUG lockTest - begin
17:09:58.215 [Thread-1] DEBUG number - 2
17:09:59.228 [Thread-0] DEBUG number - 1
6.6 应用6:
将a,b两个方法都设置为static,并且都加锁
结果(都是对number上锁):
17:13:48.887 [Thread-1] DEBUG lockTest - begin
17:13:48.893 [Thread-1] DEBUG number - 2
17:13:48.887 [Thread-0] DEBUG lockTest - begin
17:13:49.897 [Thread-0] DEBUG number - 1
6.7 应用7:
将a,b两个方法都设置为static,并且都加锁;同时修改main方法跟应用4一样;
结果(都是对number上锁,还是同步的):
17:16:50.685 [Thread-0] DEBUG lockTest - begin
17:16:50.685 [Thread-1] DEBUG lockTest - begin
17:16:51.694 [Thread-0] DEBUG number - 1
17:16:51.694 [Thread-1] DEBUG number - 2