编程(39)----------多线程中的锁

假设一个这样的场景: 在多线程的代码中, 需要在不同的线程中对同一个变量进行操作. 那此时就会出现问题: 多线程是并发进行的, 也就是说代码运行的时候, 俩个线程会同时对一个变量进行操作, 这样就会涉及到多线程的安全问题:

class Counter{
    public int count;

    public  void add(){
            count++;
    }
}
public class demo1 {
    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5000; i++) {
                counter.add();
            }
        });
        t1.start();
        t2.start();

        t1.join();
        t2.join();
        System.out.println(counter.count);
    }
}

在这个代码中, 两个线程会分别对count进行自增五千次, 按理说最后打印的结果是一万. 但实际上,多次运行后代码的结果,很难做到一万, 常见于八九千的结果. 

其原因在于, add的过程并非不可拆分的, 也就是不具有原子性. 在实际的运行中, add可以大致分为三步: 读取, 加一, 最后再赋值. 当然这并非专业的术语说法, 这里只简单的以此为描述. 

由于两个线程同时进行, 也就是都要执行这三步, 且是以抢占式进行执行. 那执行顺序就必然乱套了. 很可能会出现线程1刚将count原值读入, 线程2就将其赋值走了, 根本没来得及加一. 这种还未执行完就将其读入的操作, 也可称其为脏读. 

                                                                                 编程(39)----------多线程中的锁_第1张图片

 为避免这种乱套的多线程安全问题, 常用办法便是采用加锁(Synchronized), 其用于修饰方法和代码块. 但是特别注意, 加锁是锁的对象. 当某个对象加锁后, 只有当其再解锁后, 另一个线程才能重新获取锁, 否者会陷入阻塞等待的状态:

                                                                                编程(39)----------多线程中的锁_第2张图片

 这样的操作就能保证在执行完一整个add后再执行下一个add. 虽会降低运行速率, 但能保证代码的准确性. 代码上的修改只需将add进行加锁即可保证得到准确的结果:

//只需在此处加锁即可
    public synchronized void add(){
            count++;
    }
}

//或者代码块加锁
    public void add(){
        synchronized (this) {
            count++;
        }
    }

  若两个线程针对不同对象加锁或者一个加锁一个不加锁, 那么也不会存在阻塞等待的情况.

还有一种特殊情况: 多重锁. 即一个线程加了两把锁, 虽然说当一个线程对对象上锁后, 另一个线程是应该阻塞等待的, 但此时若上锁线程就是要访问的线程呢? 这时是否可以考虑开绿灯呢? 这就好比小偷偷不属于自己的东西, 这是不被允许的犯罪行为. 那如果他偷的是自己的东西呢? 这完全是可以的, 因为这压根就不算偷窃.

因此, 对于可以实现多重锁的关键字, 就被认为是可重入的, 反之是不可重入. 在java中的synchronized是属于可重入, 也就是说, 加上述代码合并运行, 仍可以得到正确的结果, 但并非所有的锁都支持该功能:

 //可重入
    public synchronized void add(){
        synchronized (this) {
            count++;
        }
    }

若不支持可重入, 则会陷入死锁状态, 卡在那里 一直阻塞等待.

当然, 死锁的状态并非只有上述的这一种. 第二种是两个线程两把锁, 即两个线程先分别加锁, 然后再尝试获得对方的锁:

public class demo2 {
    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();
        Thread t1 = new Thread(() -> {
            synchronized (lock1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock2){
                    System.out.println("获取锁2");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lock2){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized ((lock1)){
                    System.out.println("获取锁1");
                }
            }
        });

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

在这个代码中就能够看出, 当两个线程将锁1 锁2获取后, 要相互获取对方的锁, 但对方的锁未解锁, 因此在这种情况想两个线程都被阻塞, 不能继续运行. 在这种情况下代码会一直处于运行状态. 可以用jconsole观察到线程是属于阻塞状态.

                                                                                   编程(39)----------多线程中的锁_第3张图片

 第三种死锁即第二种死锁的一般情况, 多线程多把锁而非两把锁. 这里涉及到一个经典的吃面问题. 假设, 有一个圆桌, 共坐了五个人, 每两个人之间, 放了一根筷子. 也就是说共放了五根筷子.

假设吃面的人必须得先拿起他左边的筷子, 再拿起他右边的一根筷子. 那在这种情况下考虑极端情况, 当五个人同时都想吃面时, 会同时都拿起左边的筷子, 且右边没有筷子可拿. 这个时候就僵住了, 谁也吃不了面, 谁也不会放下筷子. 同理, 在多线程种, 每个线程就好比每个人, 每跟筷子就好比每个锁, 考虑极端情况, 会出现这种全部僵在一起的状态.

要解决这个问题, 就得先了解死锁的必要条件:\

1. 互斥使用. 线程一上锁, 线程二只能等着.

2. 不可抢占. 线程一获得锁之后, 只能自己释放锁, 而不能由线程二强行获取锁

3.保持稳定性. 若线程一已经获得锁A, 它再尝试获得锁B时, 锁A是不会因为线程一获得锁B而解锁锁A.

4.循环等待. 也就是刚才所演示的. 线程一获得锁A的同时, 线程二获得锁B. 然后线程一要获得锁B, 线程二要获得锁A, 僵持不下.

对于Synchronized而言, 其实必要条件只有第四点. 前三点是无法去改变的. 但对于其他锁来说不一定. 因此, 想要解决死锁, 就只能从, 循环等待入手.

解决方法是, 给每一把锁标号, 再按照标号的一定顺序进行加锁.

                                                                                      编程(39)----------多线程中的锁_第4张图片

以吃面来举例. 将每根筷子标号, 并规定拿筷子必须从小号开始拿. 对应多线程种按锁的标号顺序由小到大加锁. 这样的话, 一号筷子和二号筷子之间的人就拿一号, 二号筷子和三号筷子之间的人就拿二号, 以此类推.

当轮到一号筷子和五号筷子之间的人拿筷子时, 出现问题了. 由于规定按小号拿, 因此应该是拿一号筷子而非五号筷子. 但此时的一号筷子已经被占用. 因此他只能等待, 也就是多线程中的阻塞. 与此同时, 前一个人可以再拿到四号筷子的基础上拿到五号筷子, 也就是获取到锁, 从而执行多线程. 以这种方式, 就不会出现所有人都吃不到面, 避免所有线程都处于阻塞状态. 反应到代码中, 就只需将锁调换一下即可:

public class demo2 {
    public static void main(String[] args) {
        Object lock1 = new Object();
        Object lock2 = new Object();
        //标号: 锁1 为一号, 锁2 为二号. 由小到大加锁
        Thread t1 = new Thread(() -> {
            synchronized (lock1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized (lock2){
                    System.out.println("获取锁2");
                }
            }
        });
        Thread t2 = new Thread(() -> {
            synchronized (lock1){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                synchronized ((lock2)){
                    System.out.println("获取锁1");
                }
            }
        });

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

除此以外, 解决这类问题还可以使用银行家算法. 但是在实际工作中, 使用并不广泛. 因为其过于复杂, 实用性不高.

-------------------------------------------最后编辑于2023.6.1 下午两点左右

你可能感兴趣的:(java,jvm,开发语言)