Java多线程——对象及变量的并发访问
Java多线系列文章是Java多线程的详解介绍,对多线程还不熟悉的同学可以先去看一下我的这篇博客Java基础系列3:多线程超详细总结,这篇博客从宏观层面介绍了多线程的整体概况,接下来的几篇文章是对多线程的深入剖析。
本篇文章主要介绍Java多线程中的同步,也就是如何在Java语言中写出线程安全的程序,如何在Java语言中解决非线程安全的相关问题。多线程中的同步问题是学习多线程的重中之重,这个技术在其他的编程语言中也涉及,如C++或C#。
同步和异步:
1、概念:
同步:A线程要请求某个资源,但是此资源正在被B线程使用中,因为同步机制存在,A线程请求不到,怎么办,A线程只能等待下去
异步:A线程要请求某个资源,但是此资源正在被B线程使用中,因为没有同步机制存在,A线程仍然请求的到,A线程无需等待
2、特点:
显然,同步最最安全,最保险的。而异步不安全,容易导致死锁,这样一个线程死掉就会导致整个进程崩溃,但没有同步机制的存在,性能会有所提升
3、同步阻塞与异步阻塞:
一个线程/进程经历的5个状态, 创建,就绪,运行,阻塞,终止。各个状态的转换条件如上图,其中有个阻塞状态,就是说当线程中调用某个函数,需要IO请求,或者暂时得不到竞争资源的,操作系统会把该线程阻塞起来,避免浪费CPU资源,等到得到了资源,再变成就绪状态,等待CPU调度运行。
同步是指两个线程的运行是相关的,其中一个线程要阻塞等待另外一个线程的运行。异步的意思是两个线程不相关,自己运行自己的。
线程安全问题:
定义:
线程安全是多线程编程时的计算机程序代码中的一个概念。在拥有共享数据的多条线程并行执行的程序中,线程安全的代码会通过同步机制保证各个线程都可以正常且正确的执行,不会出现数据污染等意外情况。
线程安全问题概况来说有三方面:原子性、可见性和有序性。
原子性:
原子(Atomic)的字面意思是不可分割的(lndivisible)。对于涉及共享变量访问的操作,若该操作从其执行线程以外的任意线程来看是不可分割的,那么该操作就是原子操作,相应地我们称该操作具有原子性(Atomicity)。
在生活中我们可以找到的一个原子操作的例子就是人们从ATM机提取现金:尽管从ATM软件的角度来说,一笔取款交易涉及扣减户主账户余额、吐钞器吐出钞票、新增交易记录等一系列操作,但是从用户(我们)的角度来看ATM取款就是一个操作。该操作要么成功了,即我们拿到现金(户主账户的余额会被扣减),这个操作发生过了;要么失败了,即我们没有拿到现金,这个操作就像从来没有发生过一样(当然,户主账户的余额也不会被扣减)。除非ATM软件有缺陷,否则我们不会遇到吐钞口吐出部分现金而我们的账户余额却被扣除这样的部分结果。在这个例子中,户主账户余额就相当于我们所说的共享变量,而ATM机及其用户(人)就分别相当于上述定义中原子操作的执行线程和其他线程。
可见性:
在多线程环境下,一个线程对某个共享变量进行更新之后,后续访问该变量的线程可能无法立刻读取到这个更新的结果,甚至永远也无法读取到这个更新的结果。这就是线程安全问题的另外一个表现形式:可见性(Visibility)。
如果一个线程对某个共享变量进行更新之后,后续访问该变量的线程可以读取到该更新的结果,那么我们就称这个线程对该共享变量的更新对其他线程可见,否则我们就称这个线程对该共享变量的更新对其他线程不可见。可见性就是指一个线程对共享变量的更新的结果对于读取相应共享变量的线程而言是否可见的问题。多线程程序在可见性方面存在问题意味着某些线程读取到了旧数据(Stale Data),而这可能导致程序出现我们所不期望的结果。
如上图所示,线程1修改X变量,是在自己工作内存中进行修改的,并未及时刷新到主内存中,如果这时候线程2去读取主内存中的数据X读取到的还是0,但实际上X已经被修改成1了,这就是线程可见性有可能出现的问题。我们可以使用synchronized关键字来解决线程可见性问题。
有序性:
有序性(Ordering)指在什么情况下一个处理器上运行的一个线程所执行的内存访问操作在另外一个处理器上运行的其他线程看来是乱序的(Out of order)。所谓乱序,是指内存访问操作的顺序看起来像是发生了变化。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}
上面代码中的instance=new Person(),这条语句实际上包含了三步操作
分配对象的内存空间;
初始化对象;
设置instance指向刚分配的内存地址
由于重排序的原因,可能会出现以下运行顺序
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,显而易见对线程B来说之后的操作就会出错。我们可以使用volatile关键字来解决线程有序性问题
示例:
下面我们来看两个线程安全的例子:
(1)、不共享数据的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Mythread extends Thread{
private int count=5;
public Mythread(String name) {
this.setName(name);
}
@Override
public void run() {
while(count>0) {
count--;
System.out.println("由 "+this.currentThread().getName()+" 计算,count="+count);
}
}
}
public class Test01 {
public static void main(String[] args) throws InterruptedException {
/**
* 下面创建了三个线程A,B,C
*/
Mythread A=new Mythread("A");
Mythread B=new Mythread("B");
Mythread C=new Mythread("C");
A.start();
B.start();
C.start();
}
}
运行结果:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
由 B 计算,count=4
由 A 计算,count=4
由 C 计算,count=4
由 A 计算,count=3
由 A 计算,count=2
由 B 计算,count=3
由 A 计算,count=1
由 C 计算,count=3
由 A 计算,count=0
由 B 计算,count=2
由 C 计算,count=2
由 B 计算,count=1
由 C 计算,count=1
由 C 计算,count=0
由 B 计算,count=0
由结果可以看出,一共创建了三个线程,每个线程都有各自的count变量,自己减少自己的count变量的值。这样的情况就是不共享变量,不会发生线程安全问题。
(2)、共享数据的情况:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Mythread extends Thread{
private int count=3;
@Override
public void run() {
count--;
System.out.println("由 "+this.currentThread().getName()+" 计算,count="+count);
}
}
public class Test01 {
public static void main(String[] args) throws InterruptedException {
//A,B,C三个线程共享一个变量
Mythread thread=new Mythread();
Thread A=new Thread(thread,"A");
Thread B=new Thread(thread,"B");
Thread C=new Thread(thread,"C");
A.start();
B.start();
C.start();
}
}
运行结果:注意,这里的结果不一定是这样,也有可能是其他
1
2
3
由 B 计算,count=0
由 A 计算,count=1
由 C 计算,count=0
由结果我们可以知道,B和C计算的值都为0,说明B和C对count进行了同样的处理,产生了“非线程安全问题”。与我们想要的结果不同,我们希望值是依次递减的。
在JVM中,实现count--实际上一共需要三步:
取得原有的count值
计算count-1
对count进行赋值
在这三步中如果有多个线程同时访问就可能会出现非线程安全问题。假设A先执行来到第一步,获取count值为3,然后进行减一操作,A执行完后,此时count的值为2;B和C同时取得count值为2,然后同时减一,此时count值为0,因为B和C都执行了减一操作,最后赋值的时候B和C都为0
那么我们可不可以给线程设置一道“安检”,类似于过机场安检,每个人需要排队进行安检,不许抢先进行安检。
我们将Mythread的run()方法改成如下:
1
2
3
4
public synchronized void run() {
count--;
System.out.println("由 "+this.currentThread().getName()+" 计算,count="+count);
}
现在运行结果如下:
1
2
3
由 A 计算,count=2
由 B 计算,count=1
由 C 计算,count=0
我们在上面的run()方法上面加上了synchronized关键字,现在的结果就是正确的了。下面来详细介绍synchronized关键字。
synchronized关键字:
一、synchronized同步方法:
在上面的例子中我们已经初步了解了“线程安全”与“非线程安全”相关的技术点,它们是学习多线程技术时一定会遇到的经典问题。“非线程安全”其实会在多个线程对同一个对象中的实例变量进行并发访问时发生,产生的后果就是“脏读”,也就是取到的数据其实是被更改过的。而“线程安全”就是以获得的实例变量的值是经过同步处理的,不会出现脏读的现象。
1、方法内的变量为线程安全的:
非线程安全问题存在于“实例变量”中,如果是方法内部的私有变量,则不存在非线程安全问题。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class NameTest{
public void add(String name) {
try {
int num=0;
if(name.equals("a")) {
num=100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num=200;
System.out.println("b set over");
}
System.out.println(name+" num="+num);
}catch(Exception e) {
e.printStackTrace();
}
}
}
class ThreadA extends Thread{
private NameTest nA;
public ThreadA(NameTest nA) {
this.nA=nA;
}
@Override
public void run() {
nA.add("a");
}
}
class ThreadB extends Thread{
private NameTest nB;
public ThreadB(NameTest nB) {
this.nB=nB;
}
@Override
public void run() {
nB.add("b");
}
}
public class Test01 {
public static void main(String[] args) throws InterruptedException {
NameTest n=new NameTest();
ThreadA aThreadA=new ThreadA(n);
aThreadA.start();
ThreadB bThreadB=new ThreadB(n);
bThreadB.start();
}
}
运行结果:
1
2
3
4
a set over!
b set over
b num=200
a num=100
结果显示,a num=100,b num=200;说明两个线程之间并未发生非线程安全问题,因为他们操作都是之间内部的变量。
2、实例变量非线程安全:
还是上面的例子,我们只改一行代码,将NameTest修改如下,其他代码保持不变:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class NameTest{
//将num修改为全局变量
private int num=0;
public void add(String name) {
try {
if(name.equals("a")) {
num=100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num=200;
System.out.println("b set over");
}
System.out.println(name+" num="+num);
}catch(Exception e) {
e.printStackTrace();
}
}
}
现在我们来看一下运行结果:
1
2
3
4
a set over!
b set over
b num=200
a num=200
现在我们可以看到a和b的num值都为200,发生了线程安全问题。
这时我们只需要在add方法上加上 synchronized 关键字即可(public synchronized void add),此时的运行结果就正确了。
1
2
3
4
5
运行结果:
a set over!
a num=100
b set over
b num=200
实验结论:在两个线程访问同一个对象中的同步方法时一定是线程安全的。本实验由于是同步访问,b必须等待a执行完了才可以执行,所以先打印出a,然后打印出b。
3、多个对象多个锁:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class NameTest{
//将num修改为全局变量
private int num=0;
public synchronized void add(String name) {
try {
if(name.equals("a")) {
num=100;
System.out.println("a set over!");
Thread.sleep(2000);
}else {
num=200;
System.out.println("b set over");
}
System.out.println(name+" num="+num);
}catch(Exception e) {
e.printStackTrace();
}
}
}
class ThreadA extends Thread{
private NameTest nA;
public ThreadA(NameTest nA) {
this.nA=nA;
}
@Override
public void run() {
nA.add("a");
}
}
class ThreadB extends Thread{
private NameTest nB;
public ThreadB(NameTest nB) {
this.nB=nB;
}
@Override
public void run() {
nB.add("b");
}
}
public class Test01 {
public static void main(String[] args) throws InterruptedException {
//下面创建了两个NameTest对象
NameTest n1=new NameTest();
NameTest n2=new NameTest();
ThreadA a=new ThreadA(n1);
a.start();
ThreadB b=new ThreadB(n2);
b.start();
}
}
运行结果:
1
2
3
4
a set over!
b set over
b num=200
a num=100
有的读者看到这里可能有疑问了,add()方法不是已经用synchronized修饰了吗?而synchronized修饰的方法时同步方法,那么因为先把a执行完毕,再执行b,为什么会实现这样的结果?
请读者仔细阅读代码,上一个实验中我们只创建了一个NameTest对象,而这个实验中我们创建了两个NameTest对象,两个线程操作的是同一个类的不同实例,所以会产生这样的结果。synchronized实现同步其实是给需要同步执行的代码加上了锁,当A线程获取到这把锁后,其他线程便不能获得到这个锁,直到A执行完毕释放锁后,其他线程才可以去拥有这个锁然后执行相应代码。
关键字synchronized取得的锁都是对象锁,而不是把一段代码或方法(函数)当作锁,所以在上面的示例中,哪个线程先执行带synchronized关键字的方法,哪个线程就持有该方法所属对象的锁Lock,那么其他线程只能处于等待状态。前提是多个线程访问的是同一个对象。但如果多个线程访问多个对象,则JVM便会创建多个锁,上面的示例就是创建了两个锁。
4、synchronized方法与对象锁:
上面的示例中我们初步接触了锁,下面我们来深入了解一下synchronized与锁的关系。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
class MyObject{
public synchronized void methodA() {
try {
System.out.println("begin methon threadName= "+Thread.currentThread().getName());
Thread.sleep(5000);
System.out.println("end");
}catch(Exception e) {
e.printStackTrace();
}
}
public void methodB(){
try {
System.out.println("begin methon threadName= "+Thread.currentThread().getName()+" begin time= "+System.currentTimeMillis());
Thread.sleep(5000);
System.out.println("end");
}catch(Exception e) {
e.printStackTrace();
}
}
}
//线程A
class ThreadA extends Thread{
private MyObject object;
public ThreadA(MyObject object) {
this.object=object;
}
@Override
public void run() {
object.methodA();
}
}
class ThreadB extends Thread{
private MyObject object;
public ThreadB(MyObject object) {
this.object=object;
}
@Override
public void run() {
object.methodB();
}
}
public class Test01 {
public static void main(String[] args) throws InterruptedException {
MyObject object=new MyObject();
ThreadA a=new ThreadA(object);
a.setName("A");
ThreadB b=new ThreadB(object);
b.setName("B");
a.start();
b.start();
}
}
上面代码中MyObject类中共有两个方法methodA()和methodB()方法,其中methodA()方法加上了synchronized关键字,是同步方法,methodB()这是普通方法;现在有两个线程类A和B,A线程中run方法调用的是MyObject类中的methodA()方法,B线程中run()方法调用的是MyObject类的methodB()方法,main()方法中创建了两个线程,名称为A和B,现在我们来看一下运行结果:
1
2
3
4
begin methon threadName= A
begin methon threadName= B begin time= 1574825571200
end
end
从结果可以看到,两个线程并非同步运行。因为methodB()方法并非同步方法,所以当A线程启动后,B线程依然可以调用methodB()方法。
下面我们将methodB()也加上synchronized关键字,再次运行看一下结果:
1
2
3
4
begin methon threadName= A
end
begin methon threadName= B begin time= 1574825932133
end
从这次的运行结果中我们可以清楚的看到A和B同步运行,那么这是为什么呢?线程A和B调用的不是同一个方法啊?我们再来仔细研究一下创建线程的代码:
1
2
3
4
5
6
7
MyObject object=new MyObject();
ThreadA a=new ThreadA(object);
a.setName("A");
ThreadB b=new ThreadB(object);
b.setName("B");
a.start();
b.start();
首先我们创建了一个MyObject类的实例,然后创建了线程A和B的实例,我们可以看到创建线程传入的参数是相同的,都是object,所以这两个线程运行时持有的是同一把锁object,所以我们看到的运行结果是同步的。
假如现在我们把创建线程的代码改成下面这样,大家思考结果会是什么?
1
2
3
4
5
6
ThreadA a=new ThreadA(new MyObject());
a.setName("A");
ThreadB b=new ThreadB(new MyObject());
b.setName("B");
a.start();
b.start();
对,结果是不同步的,因为线程A和B用的不是同一把锁
5、synchronized重入锁:
“可重入锁”的概念是:自己可以再次获取自己的内部锁。比如有一个线程获得了该对象的锁还没有释放,当其再次想要获取这个锁时依然可以获取,如果是不可重入锁的话,就会造成死锁。
关键字synchronized拥有锁重入的功能,也就是在使用synchronized的时候,当一个线程得到一个对象锁后,该线程再次此对象的锁依然是可以得到该对象的锁。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Service{
public synchronized void se