多线程的锁与多种使用方式分析

发布于:2023-01-05 ⋅ 阅读:(338) ⋅ 点赞:(0)

开端

有这样一个题目,我们需要两个线程交替打印一个数字,看到这道题第一反应就是加锁,没什么难的。但知其然也要知其所以然,更何况多线程提供了那么多锁,例如重量锁、轻量锁、自旋锁、重入锁等。我们就尝试用多种方法、多个角度来解决这道题。

分支

这里我们创建Thread方法统一用实现Runnable接口,我们有以下几种分支:

  1. 所有Thread 共用同一个Runnable
  2. 多个Thread 分别用多个Runnable
  3. 使用Synchronized + 对象锁
  4. 使用Synchronized + 类锁
  5. 使用ReentrantLock + 同一个Condition
  6. 使用ReentrantLock + 不同Condition

这个看起来就有点晕了,下面我们挨个分析他们间的区别。

在这之前,我们要创建一些具有共识的条件:既然是要做交替打印,首先需要有int类型的全局变量number,让所有线程都操作并打印他;其次是要创建多线程具体执行的方法体,需要让他无限做累加并打印一些有用的信息,比如线程名、当前数字,并给他一些休眠时间。

通用代码块如下:

//全局变量递增数字
private static int number = 0;

//线程通用方法体
Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
        for(;;){
            String threadName = Thread.currentThread().getName();
            System.out.println("我是:" + threadName + "我的当前数字是:" + number);
            number++;
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }
};

Perhaps1 线程共用Runnable

先来一个最直观最简单的体验,我们创建两个Runnable,分别塞入两个线程并命名为Thread1和Thread2,然后启动他们:

Thread thread1 = new Thread(runnable1, "Thread1");
Thread thread2 = new Thread(runnable2, "Thread2");

thread1.start();
thread2.start();

分析一下输出的结果,两个线程启动后会同时并发执行方法,打印他们的线程名和当前数字;然后我们再来看看真实结果,也确实如我们所想,很清晰:

我是:Thread1我的当前数字是:0
我是:Thread2我的当前数字是:0
我是:Thread1我的当前数字是:1
我是:Thread2我的当前数字是:2
我是:Thread2我的当前数字是:4
我是:Thread1我的当前数字是:4
我是:Thread1我的当前数字是:5
我是:Thread2我的当前数字是:5
我是:Thread1我的当前数字是:6
我是:Thread2我的当前数字是:7
我是:Thread1我的当前数字是:8
我是:Thread2我的当前数字是:8
我是:Thread2我的当前数字是:10
我是:Thread1我的当前数字是:10

因为没有加锁,此时无论在Thread中都放入runnable1,还是分别放入runnable1/runnable2,都没什么区别。下面我们来加上Synchronized,给方法加上锁:

Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
        synchronized (this){
            for(;;){
                String threadName = Thread.currentThread().getName();
                System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                number++;
                try {
                    Thread.sleep(500);
                    notify();
                    wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }
};

Runnable runnable2 = new Runnable() {
    ......
}

Thread thread1 = new Thread(runnable1, "Thread1");
Thread thread2 = new Thread(runnable2, "Thread2");

thread1.start();
thread2.start();

加上了最常见的写法synchronized(this),这时我们用的是很典型的对象锁,锁住的对象是传入的Runnable对象本身,而下面我们创建的两个线程传入了不同的runnable1/runnable2。这时运行一下代码:

我是:Thread1我的当前数字是:0
我是:Thread2我的当前数字是:0

在输出这两行以后,程序就永远地阻塞了。分析一下代码可得知,由于两个Thread中传入的Runnable对象不同,锁住的“this”所指代的对象也就不同。通俗的说,这两条线程用着不同的两把锁,将这两把锁唤醒或者等待,对彼此不会有任何影响。这也就能解释为什么在运行一次后,就完全阻塞住,就是因为第一次打印完信息,本来想唤醒this锁下的其他线程并让自己等待;但由于当前锁下没有其他线程,没法切换到别的线程,却照例让自己陷入睡眠,就导致了两个线程都进入了阻塞状态。

要解决这个问题,只需要让“this”指代同一对象,也就是给Thread中传入同一个Runnable对象,我们再来试试,和刚刚唯一的区别就是都传入runnable1:

Thread thread1 = new Thread(runnable1, "Thread1");
Thread thread2 = new Thread(runnable1, "Thread2");

thread1.start();
thread2.start();

再来看看输出的结果:

我是:Thread1我的当前数字是:0
我是:Thread2我的当前数字是:1
我是:Thread1我的当前数字是:2
我是:Thread2我的当前数字是:3
我是:Thread1我的当前数字是:4
我是:Thread2我的当前数字是:5
我是:Thread1我的当前数字是:6
我是:Thread2我的当前数字是:7
我是:Thread1我的当前数字是:8

这次没有任何问题,那么第一个问题就解决了,在使用synchronized(this)场景下,线程是否共用Runnable直接决定了锁是否生效。

那如果一定要用两个不同的Runnable呢?这就是我们要讨论的第二个问题。

Perhaps2 线程使用各自Runnable

经过上一步的分析,我们得出关键问题就是让他们共用同一个锁,共享同一个锁的状态。

这时有两种方法,第一种方式是设置一个全局变量Object,这个Object对所有人都可见,也就能成为线程共用的对象锁。直接看代码:

//全局共享对象锁
private static Object lock = new Object();

Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
        synchronized (lock){
            for(;;){
                String threadName = Thread.currentThread().getName();
                System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                number++;
                try {
                    Thread.sleep(500);
                    lock.notify();
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }
    }
};

Runnable runnable2 = new Runnable() {
    ......
}

Thread thread1 = new Thread(runnable1, "Thread1");
Thread thread2 = new Thread(runnable2, "Thread2");

thread1.start();
thread2.start();

可以和上面的对象锁使用比较一下,唯一的区别就是多了一个全局对象lock,将synchronized()中的锁变成了lock,并且方法体内操作的锁也变成了lock。这时输出的结果无疑是正确的。

第二种方法是使用类锁,类锁包含两种情况,第一种情况是直接加在方法上,第二种情况是在方法体内指定Class。

第一种情况需要注意,该方法必须是静态方法,也就是必须是“synchronized static”,因为只有这样才能确保所有线程访问的都是在Class生成时就生成的静态方法(简单说,就是访问的是同一个方法,而不是各种对象实例化后指向的这个方法),否则会失效。第二种情况,只需要保证方法体内的锁是同一个类即可。下面是示例:

Runnable runnable1 = new Runnable() {
    @Override
    public void run() {
        synchronized (ThreadTest1.class){
            for(;;){
                String threadName = Thread.currentThread().getName();
                System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                number++;
                try {
                    Thread.sleep(500);
                    ThreadTest1.class.notify();
                    ThreadTest1.class.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
};

Runnable runnable2 = new Runnable() {
    ......
}

只需要注意让两个runnable都指向同一个类锁,且方法体内也调用该类的notify()和wait()即可。

Perhaps3 ReetrantLock 可重入锁

介绍完Synchronized后,我们介绍另一种安全度、灵活度更高的ReetrantLock 可重入锁。两者间最大的不同就是,Synchronized的加解锁由JVM自行操作,不需要我们手动释放;而ReetrantLock需要我们自己加解锁,且加了几个锁,就需要解开几次,否则其他线程拿不到这个锁。

还是先来一个最基本的使用示例,这次的代码结构就有较大的变动了,首先我们进行ReetranLock的上锁与解锁,其次因为我们要演示两个线程的执行先后次序,所以不能是无限循环。

代码如下:

//递增数字
private static int number = 0;

//可重入锁,参数传入true为公平锁,按照等待时长分配CPU资源,空参默认不公平锁
private static ReentrantLock lock = new ReentrantLock(true);

//可重入锁基本示例,只有一个线程可以执行
public static void threadTest1(){

    Runnable runnable1 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(int i = 0; i <= 5; i++) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Runnable runnable2 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(int i = 0; i <= 5; i++) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Thread thread1 = new Thread(runnable1, "Thread1");
    Thread thread2 = new Thread(runnable2, "Thread2");

    thread1.start();
    thread2.start();
}

ReetrantLock的使用需要遵守一些规范,必须在try的第一行加锁,并在finally的第一行释放锁。其次我们在方法体中,number递增5次后结束并释放锁,效果如下:

我是:Thread1我的当前数字是:0
我是:Thread1我的当前数字是:1
我是:Thread1我的当前数字是:2
我是:Thread1我的当前数字是:3
我是:Thread1我的当前数字是:4
我是:Thread1我的当前数字是:5
我是:Thread2我的当前数字是:6
我是:Thread2我的当前数字是:7
我是:Thread2我的当前数字是:8
我是:Thread2我的当前数字是:9
我是:Thread2我的当前数字是:10
我是:Thread2我的当前数字是:11

可以看到Thread1会先获取锁,执行6次累加后释放锁,Thread2紧接着获取锁并执行方法。这时是否共用一个Runnable就无关紧要了,因为他们共用同一个全局ReetrantLock锁。

那么回归我们的题目,如何用ReetrantLock实现交替打印?在Syncchronized中,我们调用了notify()和wait()来让线程交替执行,ReetrantLock自然也是有这种方法的,且更加好用灵活。

Perhaps4 单Condition

我们要用到的是和ReetrantLock配套使用的Condition,他用signal()和await()代替了notify()和wait(),使线程切换更灵活与安全。最大的不同点,ReetrantLock支持多个Condition,也就是每个线程可以有独特的Condition,可以指定切换到哪个线程;这个可以类比循环中的break跳出指定循环,“break a;”意为跳出a循环,去a的上一层执行,唤醒特定Condition意为去执行特定Condition。

下面正式用Condition实现一下交替打印,首先我们需要新建一个Condition对象,用ReetrantLock锁实例的“newCondition”来创建。下面是代码:

//递增数字
private static int number = 0;

//可重入锁
private static ReentrantLock lock = new ReentrantLock(true);

//对象监视器
private static Condition con1 = lock.newCondition();

//共用一个Condition的交替打印
public static void threadTest2(){

    Runnable runnable1 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(;;) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                    con1.await();
                    con1.signal();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Runnable runnable2 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(;;) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                    con1.signal();
                    con1.await();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Thread thread1 = new Thread(runnable1, "Thread1");
    Thread thread2 = new Thread(runnable2, "Thread2");

    thread1.start();
    thread2.start();
}

我们根据lock创建了一个con1,并在Thread1完成第一次累加后,让该线程进入队列等待,并唤醒当前等待中的Thread2;在Thread2完成累加后,再唤醒正在等待中的Thread1,让自己进入队列等待。这样就形成了一个完善的闭环,我睡你醒,你睡我醒。但是要注意,要先唤醒别人,自己再睡,不然你俩都睡了就完蛋了。意思就是队列里要有等待的线程,才能使用signal()来唤醒,否则会一直阻塞。

还有一点,此时Thread放入多个还是单个Runnable是没有区别的,因为ReetrantLock锁依然是全局的。

Perhaps5 多Condition

再使用多个Condition,两个线程分别对应Condition1/Condition2:

//使用多个Condition的交替打印
public static void threadTest3(){

    Runnable runnable1 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(;;) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                    con2.signal();
                    con1.await();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Runnable runnable2 = new Runnable() {
        @Override
        public void run() {
            try {
                lock.lock();
                for(;;) {
                    String threadName = Thread.currentThread().getName();
                    System.out.println("我是:" + threadName + "我的当前数字是:" + number);
                    number++;
                    Thread.sleep(500);
                    con1.signal();
                    con2.await();
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }
        }
    };

    Thread thread1 = new Thread(runnable1, "Thread1");
    Thread thread2 = new Thread(runnable2, "Thread2");

    thread1.start();
    thread2.start();
}

和单Condition一样的是,先要唤醒另一个线程,再让自己进入等待队列。而且两个Thread必须用不同的Runnable,因为他们有自身的Condition,无法共用。这是一种更灵活的方式,每个线程使用一个Condition,在有更多线程和更多Condtion时,可以更灵活地指定唤醒哪个线程。

比如有买家、卖家、供货商时:买家去买东西,可以唤醒卖家进行处理,然后让买家自己进入等待;卖家处理完商品,发现东西卖光了,可以去唤醒供货商来供应商品,然后让自己进入等待队列;供货商给卖家供完货,可以选择告诉卖家,让卖家去告诉买家,或者直接告诉买家你可以来购物了。这不就大大提升了灵活性吗?

结尾

在Java更新迭代的同时,Synchronized的性能也已经大大提升,在追求方便也不追求高灵活性时,可以直接使用Synchronized;但是也要做一定的优化,比如说尽量提高加锁粒度,尽量锁代码块而不是锁整个方法。在需要更安全和灵活的高并发环境时,就可以优先选用ReetrantLock+Condition,灵活调度CPU资源。

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到