【Java并发学习】之线程的同步

【Java并发学习】之线程的同步

前言

在前面一个小节中,我们学习了线程的概念以及在Java中创建任务的方式,并且将任务委托给对应的线程进行执行,本小节我们主要来学习线程之间的关系之一的同步,包含临界区、临界资源、线程同步的两种主要方法

线程的关系

从广义上来讲,线程之间有三种关系

  • 没有关系:多个线程之间相互独立,既不竞争资源,也没有任何的合作关系,只是各自完成自己的任务
  • 竞争关系:两个及以上的线程之间存在对某个或者某些资源的竞争
  • 合作关系:两个及以上的线程共同合作,完成某项任务

临界区及临界资源

学习线程之间的同步,必不可少会接触到临界区以及临界资源这两个概念,而线程之间存在竞争关系本质上就是由于临界资源的存在,而解决的方式就是使得多个线程之间能够序列化访问临界资源

  • 临界资源:临界资源指的是程序中会被多个线程共享的某个或者某些资源,可以是软件资源也可以是硬件资源,比如某个变量,某个数组,某个容器,打印机等等
  • 临界区:临界区指的是访问临界资源的代码,同步操作的主要对象

线程的同步

线程同步是一个非常重要的概念,也是在并发编程中比不可少的关键操作,需要进行同步的本质原因在于,资源的有限,由于资源的数量少于线程的数量,于是线程在访问这些资源的时候需要进行同步处理,如果没有进行同步处理,或者同步处理时不恰当,轻则会导致数据出错,重则会出现严重的并发问题

首先我们来看下没有进行同步处理所带来的后果

情景:假设现在一个公园有三个门,我们需要统计某个时刻公园里的人的总数,由于三个门的统计方式一样,所以我们可以直接采用相同的三个线程来进行统计即可


/**
 * 公园类,包含一个计数器,进入以及离开记录的操作
 */
class Park{
    private static int counter = 0;
    public void enter(){
        counter++;
    }
    public void leave(){
        counter--;
    }
    public int getCounter(){
        return counter;
    }
}

/**
 * 公园的进出登记
 */
class DoorWatcher implements Runnable{

    private Park park;

    public DoorWatcher(Park park) {
        this.park = park;
    }

    @Override
    public void run() {
        while (true){
            park.enter(); // 进入公园
            try {
                Thread.sleep(1000);// 模式人留在公园中的操作
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            park.leave(); // 离开公园
        }
    }
}

从上面的操作可以看出,如果程序正常执行,那么每个时刻公园中的人数应该是总体上保持稳定的,毕竟每个人进入公园之后会离开公园

对应的测试类如下


 public static void main(String[] args) throws InterruptedException {
        Park park = new Park();

        // 模拟公园的门的计数器
        int doorNumber = 3;
        Runnable jobs[] = new Runnable[doorNumber];
        for (int i = 0; i < doorNumber; i++){
            jobs[i] = new DoorWatcher(park);
        }
        // 执行对应的任务
        ExecutorService executor = Executors.newCachedThreadPool();
        for (int i = 0; i < doorNumber; i++) {
            executor.submit(jobs[i]);
        }
        // 定时检查公园中的人数
        while (true){
            System.out.println("current number in the park is " + park.getCounter());
            Thread.sleep(3000); // 每隔三秒检查一次
        }
    }

测试的可能结果


current number in the park is 0
current number in the park is 2
current number in the park is 2
current number in the park is 2
current number in the park is 3
current number in the park is 3
....
current number in the park is 2
current number in the park is 1
current number in the park is 1
....

执行测试代码之后,可能你会发现实际上程序的运行并不是想象中那样,而且不同次的运行可能结果还不一样,出问题的地方在于counter++以及counter-- 这两个操作,这两个操作在Java中并不是原子操作,关于原子操作,我们会在后面进行深入的学习,这两个操作都包含了取出数据,修改数据,写入数据这三个步骤,而如果没有进行同步处理,则在进行其中任何一个步骤的时候,当前线程可能被挂起,其他线程对counter进行修改,从而导致了数据的不一致,类似的情况还有很多,这里就不进行具体的分析。

由于出现问题的部分是对变量counter的操作,也就是说,这里的counter就是我们所说到的临界资源,而对应的enter以及leave方法则是对应的临界区,或者更详细的说counter++,counter--就是我们所指的临界区

解决线程同步问题的方法从广义上来讲只有一个,那就是序列化访问临界资源,也就是说,同一时刻只允许一个线程来对临界资源进行操作,这种方式有效地解决了同步问题,而具体的操作就是对临界区进行加锁处理

加锁的原理可以简单的理解为,某个线程要进入临界区之间,先申请对应的锁,如果获得该锁,则可以进入,并且将该锁上锁,离开临界区之后就将锁解开;如果没能申请到锁,说明当前时刻临界资源被其他线程占用,则自己进行阻塞,等待锁可以使用

同步方法之使用synchronized

synchronized时Java提供的一个重量级锁,或者称之为监视器,也称之为对象锁,可以用于修饰方法或者代码块,默认锁定的对象是this,也就是当前对象,也可以显示指定所要锁定的对象

修饰方法


class Park{
    private static int counter = 0;
    public synchronized void enter(){
        counter++;
        // ...
    }
    public synchronized void leave(){
        counter--;
        // ...
    }
    // ...
}

修饰代码块


class Park{
    private static int counter = 0;
    public void enter(){
        synchronized(this){
            counter++;
        }
        // ... 
    }
    public void leave(){
        synchronized(this){
            counter--;
        }
        // ...
    }
    // ...
}

synchronizd的使用比较简单,只需要在需要进行同步的方法或者代码块加上该关键字即可,当然,synchronized还有一些比较复杂的原理,这个我们将在后面学习到

同步方法之使用locks

synchronized是在比较旧的JDK中所提供的用于同步的工具,在JDK5之后,还提供了另外的工具用于进行同步,即JUC中的Lock


import java.util.concurrent.locks.ReentrantLock;

class Park{
    private static int counter = 0;

    // 申请一个锁
    private static Lock lock = new ReentrantLock();

    public  void enter(){
        lock.lock();// 加锁
        try {
            counter++;
        }finally {
            lock.unlock();//解锁
        }
    }
    public  void leave(){
        lock.lock();// 加锁
        try {
            counter--;
        }finally {
            lock.unlock();//解锁
        }
    }
    public int getCounter(){
        return counter;
    }
}

从上面的代码中可以看到,使用Lock的操作比较繁琐,我们需要自己申请锁,并且在需要加锁的时候手动加锁,然后在离开的时候进行解锁,可能你会注意到使用时的try...finally代码块,强烈建立在使用Lock的时候采用这种方式,因为在进行资源操作的时候,可能会发生异常,采用这种方式可以保证无论在什么时候都能将锁进行解锁,还记得finally的作用吗?_

Lock的使用虽然比较繁琐,而且还需要自己手动加锁、解锁,但是Lock也有synchronized所不具备的特点,那就是灵活,关于这两者的具体区别,我们将在后面的内容中学习到

总结

本小节我们主要学习了线程同步的概念,临界资源、临界区的概念,没有加锁的可能带来的危害,以及常见的同步方式,synchronized的使用以及Lock使用

你可能感兴趣的:(【Java并发学习】之线程的同步)