【JAVA进化论】LV5-3:java里的同步锁

一、为什么会存在线程安全问题?

1.1:用户如何访问我的程序?

我们前面了解了java如何开启一个线程做异步处理,也知道了在实际的项目里,我们写的的web程序也被tomcat安排成了多线程调用的程序。

如果现在只有一个用户访问我们的web程序,这个过程再针对tomcat细化下,如下:

图1

tomcat接收客户端请求的流程大致如上图所示,但是我们也可以同时接收和响应多个用户的请求和响应,当请求数据包进入我们的tomcat时,它会从它自带的线程池里取出一个空闲的线程,用于执行具体的servlet逻辑,也就是你写的业务程序。

线程池:事先申请好一批线程,存放到一个结构里(可以是数组,也可以是集合,能存线程对象就行),这样就不用每次进来一个请求就去向操作系统申请一个线程,申请线程开销是比较大的,事先开启一批放到一个结构里,用的时候直接拿来用即可,这种技术叫池化技术,之后的数据库连接池也属于一种池化技术,本质上都是避免开销大的操作,让它们事先准备好,不至于用的时候现用现申请,可以提升程序效率。

注意,线程是操作系统层面的东西,只是每种语言都支持申请,像java之前的new Thread语句在创建一个Thread对象的同时,也会向操作系统申请创建一个实际的线程出来,Thread类其实只是对申请的线程的抽象,你可以通过这个类来操纵具体的线程,就像你可以利用HttpServletRequest类在Servlet场景里操作HTTP协议信息一样。

图1里tomcat的处理模式只有一个客户端一次请求,试想一下,你这个tomcat程序放到服务器上是要给全国人民访问的,我们来结合压测来理解,我们单机一个接口的压测成绩在500qps我们都会觉得超级低,而我们的接口平均响应时间在20ms,如果就是一个用户访问我们的tomcat,完成后再让第二个用户访问,以此类推,这样显然是不合理的,让我们做个数学题,每个用户访问我们这个接口都消耗20ms,1s是1000ms,如果是按照排队访问的方式,1s只能处理(1000/20 = 50)个用户的请求,然而我们觉得这种接口,不压到1000qps都算是非常非常低的成绩。既然是这样,tomcat在处理用户请求的时候,就不可能是处理完一个再处理一个,一定是一起处理的,基于此,我们引出多线程下的tomcat处理模式,来看下多线程模式下的tomcat运行方式,拿之前的例子StudentInfoController来说事:

图2

通过图2可以更清晰的看到,多个用户同时访问你的web程序时的样子,可以看到进来的请求都是被从线程池里取到的线程并发执行的,换句话说,下面的代码是会被多线程并发执行的:

代码块1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class StudentInfoController extends HttpServlet {

private StudentDao studentDao = new StudentDao();

@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
resp.setContentType("application/json");
try {
Student result = studentDao.getStudentInfo();
resp.getOutputStream().write(JSON.toJSONBytes(result));
resp.setStatus(200);
} catch (Exception e) {
e.printStackTrace();
resp.setStatus(500);
}
}
}

1.2:线程安全问题

先记住1.1关于tomcat的访问分发模式。我们现在来看下线程安全问题主要是指什么。

线程安全问题主要是指多条线程对同一个公共资源的操作,导致的一系列”违反常规“的事情,举个例子,现在来定义一个类:

代码块2
1
2
3
4
5
6
7
8
9
10
11
12
public class Adder {

private int i = 0;

public void incre() {
i++;
}

public int getI() {
return i;
}
}

类很简单,有一个叫i的属性,incre方法用来给自己这个属性做+1操作,getI用于返回这个属性值。

来编写下main方法,我们让同一个Adder对象被两个不同的线程做incre的调用:

代码块3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) throws Exception {

Adder adder = new Adder();

Thread t1 = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
adder.incre(); //第一个线程让Adder对象里的i自增1000次
}
});

Thread t2 = new Thread(() -> {
for (int j = 0; j < 2000; j++) {
adder.incre(); //第二个线程让Adder对象里的i自增2000次
}
});
//启动线程
t1.start();
t2.start();
//利用线程睡眠,让main线程睡眠2s,等待上面两个线程执行完毕
Thread.sleep(2000L);

System.out.println(adder.getI());
}

第一个线程让i自增1000次,第二个线程让i自增2000次,我们这时来猜一下最终运行结果应该是多少?3000吗?是的,看逻辑是3000没错!可是运行下,多运行几遍,就会发现诡异的事情发生了:

代码块4
1
2
3
4
5
6
2160
2698
3000
3000
2863
3000

我运行了6次,得到的结果让人惊讶,不仅不完全正确,甚至很”随机“,这便是线程安全问题。

还记得一开始说的吗?造成线程安全问题的原因主要是因为多个线程同时操作同一公共资源造成的,在本例中,Adder的对象就是个公共资源,自然它内部的i属性也是个公共资源了。那这种线程安全问题到底是如何导致的呢?我们根据本例进行分析一下:

图3

如图所示,仿照incre的逻辑,做了一个流程图,incre的逻辑一定是先从Adder对象里拿到i的值,然后做+1计算,然后将计算结果重新写回对象的i属性,这没问题,在单线程下运行良好,但是现在线程2也做类似的操作,这时就有问题了,因为某一时刻,从内存取到的i值可能是相同的,例如第一次调用incre方法时,线程1和线程2同时取i的值,此时都是0,然后各自完成incre的调用,再将i写回对象,发现问题了吗?虽然线程1和线程2都执行了incre方法,但最终俩线程写回内存的i的值都是1,这就是为什么结果会不准的原因,不过你会发现,结果除了不准,它还随机,这就牵扯到多线程调用的一个随机性,线程是独立的、同时运行的,所以我前面描述的问题有可能发生,也有可能不发生,但只要发生一次,就足以影响结果的准确性,而发生的次数又是随机的,这也是结果为什么随机的原因。

二、synchronize关键词的作用

2.1:synchronize是干啥的?

首先它是个方法的修饰词,可以修饰成员方法,也可以修饰静态方法,也可以单独作为域来修饰一个代码块,被它修饰的方法或代码块,相当于加了个同步锁,在被多线程访问时,线程会发生”排队“,也就是后面会说的锁竞争,从而保证一个方法在多线程方法下有且仅有一个线程可以运行它。

2.2:对象锁

2.2.1:利用对象锁解决Adder类的累加问题

作用在对象上的同步锁,以对象实例为单位给访问它的线程进行”排队“,我们上面的例子就符合这种规则,我们现在让上面的incre方法变成一个同步方法(被synchronize修饰的方法,被称作同步方法):

代码块5
1
2
3
4
5
6
7
8
9
10
11
12
public class Adder {

private int i = 0;

public synchronized void incre() { //改成同步方法
i++;
}

public int getI() {
return i;
}
}

现在再来测试下代码块3里的内容:

代码块6
1
2
3
4
5
6
3000
3000
3000
3000
3000
3000

事实上,incre方法加了锁之后,不管运行几遍,程序始终都是正确的3000次。

为什么呢?再试着理解下图3,看看不加锁的时候是怎么导致最终结果不正确的?就是因为两个线程同一时刻访问统一资源导致的,那么再结合我们2.1中所描述的,加了synchronize的方法,在有多个线程同时访问自己的时候,只放行一个,另外一个排队,直到第一个线程执行完毕释放掉锁后,另外一个线程才可能进入:

图4

由于锁的控制,我们如图3里的线程安全性问题被解决了~

本例中的锁属于同步锁里的对象锁,它的作用域仅限于对象本身,一个线程要想访问它内部被synchronize修饰了的方法、代码块,则必须要获取当前对象的锁,就像图4中那样。

代码块7
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ObjectLock {

private int i = 0;

public synchronized void incre() {
i++;
}

public synchronized void decre() {
i--;
}

public void setI(int i) {
System.out.println("我不在同步块内,我可以被多个线程同时触发运行");
//这时同步块,只有线程竞争到对象锁时才能访问
synchronized (this) { //同步代码块,括号里声明为this,代表这个代码块上的锁是对象锁
this.i = i;
}
}

}

如上,任何线程要想调用incre、decre、setI的同步代码块,都必须要竞争到当前ObjectLock对象的对象锁,否则排队等待别的线程释放。

2.2.2:对象锁的作用域

通过前面的了解,对象锁指的是当前线程获得了某个实例的锁,然后对其内部”上了锁“的资源的访问,举个例子,有个Word类,有A、B两个同步方法,C属于普通方法,如图所示:

图5

可以发现,对象锁的作用域只针对当前对象生效,就像w1w2里的A方法可以被不同的线程同时执行,但是同一个对象内的同步块,却只允许持有当前对象锁的线程执行,如t2t3均被挡在了外面,当t1释放锁以后,t2t3才会重新竞争锁,竞争到锁以后就会执行自己想要执行的同步逻辑。

这跟我们后面要说的类锁很不一样。

2.3:类锁

跟对象锁的目标是一致的,就是控制线程同时访问,与对象锁不同的是,它是按照类来进行上锁的,就像普通方法和static方法一样,一个属于对象范畴的,一个属于类范畴的。

一般使用synchronize修饰的static方法,或者这样声明同步块:

代码块8
1
2
3
synchronized (XXX.class){ //跟对象锁用this做声明不一样,类锁使用class本身做声明
...略
}

类锁的作用域跟对象锁不太一样,改造下Word类,让其方法都变成静态的,图5里的访问就要变成下面这样:

图6

跟上面相比较,这里的t5受到了t1的影响,因为t1获得了Word类的锁,w1w2共属一个类,因此t1获得类锁以后,其他线程想要访问这个类里的同步块,就得等到t1释放锁以后才可以继续竞争锁然后执行自己想要执行的同步逻辑。

2.4:死锁

死锁就是指两个线程互相等待对方释放锁资源的现象,举个例子:线程1持有对象a的对象锁,线程2持有对象b的对象锁,此时a对象里需要调用b对象的同步方法,而对象b也需要调用对象a的同步方法,这时线程1在等着获取对象b的对象锁,线程2等着获取对象a的对象锁,互不撒手,此时就导致程序发生了死锁,我们通过代码来说明下这个问题:

定义一个DeadLock类:

代码块9
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
public static void main(String[] args) {

Object a = new Object(); //为了便于说明问题,我们随便new个a对象
Object b = new Object(); //为了便于说明问题,我们随便new个b对象

Thread t1 = new Thread(() -> {
synchronized (a) {
System.out.println("线程1已经获取到了a对象的对象锁,进入同步块~");
//假设接下来需要获取b对象的对象锁才能正常运行
synchronized (b) {
System.out.println("线程1已经获取到了b的对象锁,进入同步块~");
}
}
});

Thread t2 = new Thread(() -> {
synchronized (b) {
System.out.println("线程2已经获取到了b对象的对象锁,进入同步块~");
//假设接下来需要获取a对象的对象锁才能正常运行
synchronized (a) {
System.out.println("线程2已经获取到了a的对象锁,进入同步块~");
}
}
});

t1.start();
t2.start();
}

这个程序输出:

代码块10
1
2
线程1已经获取到了a对象的对象锁,进入同步块~
线程2已经获取到了b对象的对象锁,进入同步块~

然后阻塞,一直阻塞,这俩线程算是废了,这就是死锁,线程1和线程2各自等着对方释放自己需要的锁,可是这是不可能的,因为同步块不执行完锁是没办法释放的。

三、锁的种类有哪些?synchronize属于哪一种?

锁的分类太多太杂,感兴趣的话可以通过美团技术团队这篇文章来了解:不可不说的Java“锁”事

synchronized在实现上属于一种可重入非公平锁,之后会结合java底层有关synchronized的实现来给具体的说一说同步锁。