Java多线程03——线程安全和线程同步

时间:2023-02-07 07:16:35

Java多线程03——线程安全和线程同步

1 线程的同步安全

1.1 线程安全问题

设计并发编程的目的是为了使程序获得更高的执行效率,但绝不能出现数据一致性问题。比如多个渠道共同出售电影票,如果没有进行安全控制,就会出现座位被超卖的情况。我们不可能让多个人坐在同一个座位上。

如果并发程序连最基本的执行结果准确性都无法保证,那并发编程就没有任何意义。

1.2 出现数据不正确的原因

如果一个资源(变量/对象/文件/数据)同时被很多线程使用,就可能会现数据不一致的问题,也就是我们说的线程安全问题。这样的资源被称为共享资源或临界区。

通过前面内容的介绍,我们知道,如果要在不同线程之间实现资源共享,需要通过实现 ​​Runnable​​ 接口来创建线程类。

public class Test implements Runnable {
private int count = 0;

@Override
public void run() {
for(int i=0;i<3000;i++){
count++;
}
}

public static void main(String[] args) {
Test test = new Test();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();

//线程合并,让main线程等待t1/t2线程执行完成
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("count的数值是: " + test.count);
}
}

执行结果(每次运行的结果可能和这个不一样):

count的数值是: 4577

在程序中我们对count的数值原来是6000,因为有两个线程在同时执行,但是结果输出却变为了 4577,前一线程的结果还没有写入到内存时,另一线程又调用了该数据,发生了数据覆盖。这就造成了数据的不一致,引发线程的不安全。

1.3 互斥访问之​​synchronized​

互斥锁,顾名思义,就是互斥访问目的的锁。

如果对临界资源加上互斥锁,当一个线程在访问该临界资源时,其他线程便只能等待。

在Java中,每一个对象都拥有一个锁标记(monitor),也称为监视器,多线程同时访问某个对象时,只有拥有该对象锁的线程才能访问。

在访问临界资源方法中,增加同步锁

public class Test implements Runnable {
private int count = 0;

@Override
public synchronized void run() {
for(int i=0;i<3000;i++){
count++;
}
}

public static void main(String[] args) {
Test test = new Test();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();

try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("count的数值是: " + test.count);
}
}

count的数值是: 6000

使用同步锁,将并行变为了串行,在被锁住的代码块被前面的线程执行完成后,才能被后面线程继续执行,这样就保证的数据的一致性。

2 线程的同步方法和同步块

2.1 同步代码块

同步块的根本目的,是控制竞争资源能被安全访问,因此只要在访问竞争资源的时候保证同一时刻只能有一个线程访问即可,所以Java引入了同步代码块的策略,以提高性能。

synchronized(obj){
同步代码块;
}

obj叫做同步监视器(即锁对象),任何线程进入下面同步代码块之前必须先获得对obj的锁;其他线程无法获得锁。

锁对象可以是任意对象,但必须保证是同一对象,任何时刻只能有一个线程可以获得对同步监视器的锁定。当同步代码块执行完成后,线程会释放对该同步监视器的锁定。

2.2 同步方法

互斥锁加在方法上,如 1.3 中的代码示例。这种方式是将整个方法进行锁定,如果方法中存在非竞争资源,那么这种方式将会降低程序并行效率,削弱多线程并行执行的优势。

2.3 同步代码块的好处

为了避免2.2中存在的不足,可以减少锁定的代码范围,可以将锁标识加在方法内部,存在竞争资源的代码块上,有利于提高程序并行效率。

public class Test implements Runnable {
private int count = 0;

@Override
public void run() {
System.out.println("访问开始");
synchronized(this) {
for (int i = 0; i < 3000; i++) {
count++;
}
}
System.out.println("访问结束");
}

public static void main(String[] args) {
Test test = new Test();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();

try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("count的数值是: " + test.count);
}
}

运行后,会出现如下结果:

访问开始

访问开始

访问结束

访问结束

count的数值是: 6000

3 线程的死锁

当一个线程永远地持有一个锁,并且其他线程都尝试去获得这个锁时,那么它们将永远被阻塞。

例如:

线程A持有锁L并且想获得锁M,线程B持有锁M并且想获得锁L,那么这两个线程将永远等待下去,这种情况就是最简单的死锁形式。

创建资源类,有两个方法:
firstSecond() 先锁住 first 对象,再去锁住 second 对象;
secondFirst() 先锁住 second 对象,再去锁住 first 对象.

public class DeadLock {
private Object first = new Object();
private Object second = new Object();

public void firstSecond() throws InterruptedException {
synchronized (first){
System.out.println("进入到firstsecond方法");
//休眠2秒,等待其它线程启动
Thread.sleep(2000);
synchronized (second){
System.out.println("进入firstsecond方法内层");
}
}
}

public void secondFirst() throws InterruptedException {
synchronized (second){
System.out.println("进入到secondFirst方法");
//休眠2秒,等待其它线程启动
Thread.sleep(2000);
synchronized (first){
System.out.println("进入secondFirst方法内层");
}
}
}
}

线程类,调用 firstSecond() 方法

public class Thread1 extends Thread {
DeadLock deadLock;

public Thread1(DeadLock deadLock) {
this.deadLock = deadLock;
}

@Override
public void run() {
try {
this.deadLock.firstSecond();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

线程类,调用 secondFirst() 方法

public class Thread2 extends Thread {
DeadLock deadLock;

public Thread2(DeadLock deadLock) {
this.deadLock = deadLock;
}

@Override
public void run() {
try {
this.deadLock.secondFirst();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}

测试类

public class Test {
public static void main(String[] args) {
DeadLock deadLock = new DeadLock();
new Thread1(deadLock).start();
new Thread2(deadLock).start();
}
}

当线程1调用 firstSecond() 方法时,先锁住 first 对象,再去获取 second 对象时,此时,second对象已被线程2调用 secondFirst() 方法锁住;

而 secondFirst() 方法先锁住了 second 对象,又要去获取 first 对象时,该对象也已被 firstSecond() 方法锁住;

两个线程互相等待对方释放锁,从而造成了死锁。

4 线程的明锁

4.1 锁对象Lock

在Java5中,专门提供了锁对象Lock,利用锁可以方便的实现资源的*,用来对竞争资源并发访问控制。

Lock 所有加锁和解锁的方法都是显式的。

  • ​Lock.lock() ​​获取锁
  • ​Lock.unlock()​​ 释放锁

Lock可以构建公平锁和非公平锁,默认是非公平锁(概念参见本文下方)。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class Test implements Runnable {
private int count = 0;

Lock lock = new ReentrantLock();

@Override
public void run() {
System.out.println("访问开始");
lock.lock();

for (int i = 0; i < 3000; i++) {
count++;
}

lock.unlock();
System.out.println("访问结束");
}

public static void main(String[] args) {
Test test = new Test();
Thread t1 = new Thread(test);
Thread t2 = new Thread(test);
t1.start();
t2.start();

try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}

System.out.println("count的数值是: " + test.count);
}
}

执行后输出固定为如下内容:

访问开始

访问开始

访问结束

访问结束

M的数值是: 6000

4.2 lock与synchronized比较

  • ​Lock​​​ 不是Java语言内置的,​​synchronized​​是Java语言的关键字,因此是内置特性。Lock是一个类,通过这个类可以实现同步访问。
  • ​synchronized​​​ 不需要手动释放锁,当 ​​synchronized​​​ 方法或者 ​​synchronized​​​ 代码块执行完成之后,系统会自动让线程释放对锁的占用;而 ​​Lock​​ 则必须要用户手动释放锁,否则可能导致死锁现象。

5 线程的公平锁和非公平锁

​Java​​​ 的 ​​ReenTranLock​​ 也就是用队列实现的锁;

锁包含公平锁和非公平锁:

  • 在公平锁中,如果有另一个线程持有锁或者有其他线程在等待队列中等待这个锁,那么新发出请求的线程将被放入到队列中,所有线程得到锁的机会均等。
  • 而非公平锁中,只有当锁被某个线程持有时,新发出请求的线程才会被放入队列中(此时和公平锁一样),非公平锁对锁的获取是乱序的。

差别在于,非公平锁会有更多的机会去抢占锁。

5.1 使用非公平锁

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class UserRunn implements Runnable {

private int count = 10;

private boolean isOver = false;

//false: 非公平锁; true: 公平锁
private Lock lock = new ReentrantLock(false);

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "欢迎购票");

while(!isOver){
lock.lock();

System.out.println(Thread.currentThread().getName() + " 获取了第 " + count-- + " 张票");

if(count <= 1){
isOver = true;
}

lock.unlock();
}
System.out.println(Thread.currentThread().getName() + "购票完毕");
}

public static void main(String[] args) {
UserRunn userRunn = new UserRunn();
new Thread(userRunn, "售票口1").start();
new Thread(userRunn, "售票口2").start();
new Thread(userRunn, "售票口3").start();
}
}

非公平锁结果:

售票口1欢迎购票

售票口1 获取了第 10 张票

售票口1 获取了第 9 张票

售票口1 获取了第 8 张票

售票口1 获取了第 7 张票

售票口1 获取了第 6 张票

售票口1 获取了第 5 张票

售票口1 获取了第 4 张票

售票口1 获取了第 3 张票

售票口1 获取了第 2 张票

售票口1购票完毕

售票口2欢迎购票

售票口3欢迎购票

售票口3购票完毕

售票口2购票完毕

线程1抢占了所有锁资源。

5.2 使用公平锁

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class UserRunn implements Runnable {

private int count = 10;

private boolean isOver = false;

//false: 非公平锁; true: 公平锁
private Lock lock = new ReentrantLock(true);

@Override
public void run() {
System.out.println(Thread.currentThread().getName() + "欢迎购票");

while(!isOver){
lock.lock();

System.out.println(Thread.currentThread().getName() + " 获取了第 " + count-- + " 张票");

if(count <= 1){
isOver = true;
}

lock.unlock();
}
System.out.println(Thread.currentThread().getName() + "购票完毕");
}

public static void main(String[] args) {
UserRunn userRunn = new UserRunn();
new Thread(userRunn, "售票口1").start();
new Thread(userRunn, "售票口2").start();
new Thread(userRunn, "售票口3").start();
}
}

公平锁结果:

售票口1欢迎购票

售票口3欢迎购票

售票口2欢迎购票

售票口1 获取了第 10 张票

售票口3 获取了第 9 张票

售票口2 获取了第 8 张票

售票口1 获取了第 7 张票

售票口3 获取了第 6 张票

售票口2 获取了第 5 张票

售票口1 获取了第 4 张票

售票口3 获取了第 3 张票

售票口2 获取了第 2 张票

售票口2购票完毕

售票口1 获取了第 1 张票

售票口1购票完毕

售票口3 获取了第 0 张票

售票口3购票完毕

所有线程获取锁的机会相当。