Java 8 并发之同步与锁

导读:本篇文章讲解 Java 8 并发之同步与锁,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com

前言

点击查看原文: 原文地址

欢迎浏览Java 8 并发教程的第二部分.本教程致力于使用简单而易于理解的代码实例来教授你关于java8中并发编程一些知识。接下来你会学到在并发编程中使用synchronized关键字,信号来同步可变的共享变量。

Java 并发API于Java5首次加入,在后来发布的版本中不断迭代完善。本文中出现的大部分概念也适合java8以下的版本,不单单针对java8。但是本文将大量使用java8 中的 lambda表达式以及新的并发功能,如果你对lambda表达式不是很熟悉的话可以查看这个教程:Java8 教程

本文的代码示例中使用了两个帮助函数:sleep(seconds)stop(executor)

    public static void stop(ExecutorService executor) {
        try {
            executor.shutdown();
            executor.awaitTermination(60, TimeUnit.SECONDS);
        }
        catch (InterruptedException e) {
            System.err.println("termination interrupted");
        }
        finally {
            if (!executor.isTerminated()) {
                System.err.println("killing non-finished tasks");
            }
            executor.shutdownNow();
        }
    }

    public static void sleep(int seconds) {
        try {
            TimeUnit.SECONDS.sleep(seconds);
        } catch (InterruptedException e) {
            throw new IllegalStateException(e);
        }
    }

Synchronized

上一篇文章中我学习了如何通过executor服务来执行并发任务。在并发开发当中,多个线程并发访问共享可变变量时必须要格外注意。假如我们要用多个线程来使一个整数不断的增加的需求,看下面代码:

我们定义了一个每次将count变量加1的函数increment()

int count = 0;

void increment() {
    count = count + 1;
}

当我们从多个线程并发调用这个方法时会产生错误的结果

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::increment));

stop(executor);

System.out.println(count);  // 9965

代码每次执行得到的结果都不是我们期望的10000。原因就在于我们让多个线程共享了可变的变量而没有同步,这样就导致了 Race condition.

我们来分析一下这是如何发生的。首先让一个数字增加,计算机需要3步:1.读取变量当前值 2.在这个值上加1 3.将新值赋值给原来的变量。那么如果两个线程同时执行了第一步,那么值就会差1。例如在某一时刻,两个线程同时读取了50这个值,那么相当于我们的方法调用了两次却得到了51,而不是52。

幸运的是Java在其早期就通过synchronized关键字支持了线程同步,下面的代码修正了上例的错误。

synchronized void incrementSync() {
    count = count + 1;
}

当我们并发使用incrementSync()函数时,得到的结果始终是正确的10000。

ExecutorService executor = Executors.newFixedThreadPool(2);

IntStream.range(0, 10000)
    .forEach(i -> executor.submit(this::incrementSync));

stop(executor);

System.out.println(count);  // 10000

synchronized 关键字也可以用在方法内,不只是方法上

void incrementSync() {
    synchronized (this) {
        count = count + 1;
    }
}

那么synchronized关键字做了什么呢?Java 内部使用monitor 也叫做 监控锁或者内部锁来管理同步。这个monitor是绑定到对象上的。例如多线程调用同一个同步过的方法,这些方法的monitor都是同一个,而这个monitor是属于某个对象的。

所有隐式monitor都是想了可重入特性。可重入的意思是:锁是绑定到当前线程的,一个线程可以多次安全的获取到同一个锁而不会进入死锁。

Lock

除了使用synchronized关键字使用隐式锁外,Java 并发API还通过Lock接口支持各种各样的显式锁。显式锁为了获得对锁控制的原因需要支持需要方法,这与隐式锁相比无疑增加了成本,更加昂贵,需要更多计算机资源。

接下来我们介绍一下标准 JDK支持的几种锁的实现

ReentrantLock

ReentrantLock 是一个与通过synchronized关键字实现的隐式锁有相同基本行为的互斥锁,但是它的功能更加强大。顾名思义,这个锁与隐式锁一样,也实现了重入特性。

让我们使用ReentrantLock来重写一下上面的例子

ReentrantLock lock = new ReentrantLock();
int count = 0;

void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();
    }
}

通过lock()来获取一个锁,通过unlock()来释放一个锁。使用try/finally块来保证代码始终可以释放锁,这个非常重要。与使用synchronized一样,这个方法是线程安全的。如果A线程通过调用lock()战友了锁定部分的代码执行权,那么B线程也试图调用这段代码,就会被阻塞,直到A线程释放锁。任何时刻只能有一个线程持有这个锁。

前面已经说过,锁为了获得更好的控制权还支持了很多方法,看下面的例子

ExecutorService executor = Executors.newFixedThreadPool(2);
ReentrantLock lock = new ReentrantLock();

executor.submit(() -> {
    lock.lock();
    try {
        sleep(1);
    } finally {
        lock.unlock();
    }
});

executor.submit(() -> {
    System.out.println("Locked: " + lock.isLocked());
    System.out.println("Held by me: " + lock.isHeldByCurrentThread());
    boolean locked = lock.tryLock();
    System.out.println("Lock acquired: " + locked);
});

stop(executor);

上例中,第一个线程会持有锁1秒钟,那第二个线程获取了当前锁的各种状态

Locked: true
Held by me: false
Lock acquired: false

tryLock()lock()的一个替代方法,这个方法不阻塞当前线程,我们可以根据这个方法的执行结果来决定是否允许访问共享数据。

ReadWriteLock

ReadWriteLock是另种锁,这种锁维护了一对锁,读锁写锁 。为什么需要读写锁呢,因为当一个共享数据被多个线程并发读取是安全的,只要期间没有写入操作。所以只要没有线程持有写锁,那么读锁是可以被多个线程同时持有的。我们为什么这么干呢?为了提高某些读取频率高于写入频率操作的效率。大家可能听过,同步强行将多线程编程了单线程这种说法。例如两个哥们去找一个叫如花的姑娘啪啪啪,如果如花每次只允许一个哥们和她啪啪啪,那假设每个哥们啪啪啪都需要10分钟,那就要总共20分钟才能完事,这被抓的风险大大增加了啊!(没有使用读写锁的结果)。如果如花允许两个哥们一起和她啪啪啪,总时间大概只需要10左右(使用了读锁)。那读锁效率这么高我们就每次使用呗,不行啊,使用读锁是有条件的,就是这个共享数据不坏破坏(如花的承受住,不发生问题)。

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();

executor.submit(() -> {
    lock.writeLock().lock();
    try {
        sleep(1);
        map.put("foo", "bar");
    } finally {
        lock.writeLock().unlock();
    }
});

Runnable readTask = () -> {
    lock.readLock().lock();
    try {
        System.out.println(map.get("foo"));
        sleep(1);
    } finally {
        lock.readLock().unlock();
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

上面的代码先获取一个写入锁,1秒后向map里面写入一条数据。但是在写入数据之前,再提交两个任务,从map里读取数据并沉睡1秒钟。当你执行这段代码时候会发现,两读取任务必须等待写入任务完成才能开始。而一旦写入任务完成,读取任务开始后,两个读取任务是并行执行的,不用等待其中一个执行完成。这是因为,写入锁没有被其他线程持有那么读取锁支持并发读取。

StampedLock

Java 8 新加入了一种叫StampedLock的锁,类似于ReadWriteLock它也支持读写锁。与ReadWriteLock相比,它的有关锁的方法会返回一个long类型的标志。我们可以使用这个标志来释放一个锁或者去校验一个锁是否可用的,而且StampedLock 还支持一种叫做乐观锁定(optimistic locking)的锁模式。

下面让我们使用StampedLock来重写上面的例子

ExecutorService executor = Executors.newFixedThreadPool(2);
Map<String, String> map = new HashMap<>();
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        sleep(1);
        map.put("foo", "bar");
    } finally {
        lock.unlockWrite(stamp);
    }
});

Runnable readTask = () -> {
    long stamp = lock.readLock();
    try {
        System.out.println(map.get("foo"));
        sleep(1);
    } finally {
        lock.unlockRead(stamp);
    }
};

executor.submit(readTask);
executor.submit(readTask);

stop(executor);

通过readLock()或者writeLock()获取一个读锁或者写锁,同时会返回一个标识,这个标识用来在finally中来释放锁。一定要牢记 标识锁没有实现重入特征。当没有锁可用时,每一次试图获取锁都会返回一个新的标识即使相同的线程已经持有一个锁了。所以你必须特别小心防止发生死锁。

与前一个ReadWriteLock的例子类似,两个读取任务必须等待写入任务完成,释放写锁后才会开始。两个读取任务将会同时打印数据到控制台上,这是因为只要不持有写锁,多个持有读锁的任务就不必互相等待。

下面的例子展示了乐观锁定

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.tryOptimisticRead();
    try {
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(1);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
        sleep(2);
        System.out.println("Optimistic Lock Valid: " + lock.validate(stamp));
    } finally {
        lock.unlock(stamp);
    }
});

executor.submit(() -> {
    long stamp = lock.writeLock();
    try {
        System.out.println("Write Lock acquired");
        sleep(2);
    } finally {
        lock.unlock(stamp);
        System.out.println("Write done");
    }
});

stop(executor);

可以通过tryOptimisticRead()来获取一个乐观锁,这个方法会返回一个标识而且不会阻塞线程,无论锁是否可得。如果已经有一个写锁处于激活状态,那么这个方法将会返回一个等于0的标识。你可以随时使用lock.validate(stamp)来检查某个标识是否是合法的。

执行上面的代码,结果如下

Optimistic Lock Valid: true
Write Lock acquired
Optimistic Lock Valid: false
Write done
Optimistic Lock Valid: false

当获取锁的时候乐观锁是合法的。和其他普通读锁不同的是,乐观锁不会阻止其他线程立刻获取一个写锁。在上面的例子中,我们让第一个线程沉睡了1秒,让第二个线程在第一个线程释放乐观读锁之前去获取写锁。从此刻开始,乐观读锁已经不再合法。即使在写锁释放以后客观读锁依然处于不合法状态。

所以当你在使用乐观锁时,你必须每次在访问了任何共享可变变量后验证这个锁是否还处于合法状态。

有时候在不通过先释放锁再获取锁这个过程而是直接将一个读锁转换为一个写锁很重要。StampedLock 提供了tryConvertToWriteLock()方法来达到这个目的。

ExecutorService executor = Executors.newFixedThreadPool(2);
StampedLock lock = new StampedLock();

executor.submit(() -> {
    long stamp = lock.readLock();
    try {
        if (count == 0) {
            stamp = lock.tryConvertToWriteLock(stamp);
            if (stamp == 0L) {
                System.out.println("Could not convert to write lock");
                stamp = lock.writeLock();
            }
            count = 23;
        }
        System.out.println(count);
    } finally {
        lock.unlock(stamp);
    }
});

stop(executor);

我们先获取了一个读锁并打印cout变量当前值到控制台。但是当count值为0时,我想设置它为23。为了不破坏线程安全性,我们必须将读锁转换成写锁。调用tryConvertToWriteLock()方法不会阻塞线程但是当目前没有写锁可以获取时就会返回一个为0 的标识。如果是那样我们就调用writeLock()阻塞当前线程直到写锁可以获得为止。

Semaphores

并发API 除了提供锁以外还支持计数信号量。锁通常用来保证变量或者资源的独占性,而信号量可以维护整个权限集合。这在需要限制对程序某一部分并发访问量的场景下非常有用。

下面这个例子展示了如何限制对一个耗时任务的并发访问

ExecutorService executor = Executors.newFixedThreadPool(10);

Semaphore semaphore = new Semaphore(5);

Runnable longRunningTask = () -> {
    boolean permit = false;
    try {
        permit = semaphore.tryAcquire(1, TimeUnit.SECONDS);
        if (permit) {
            System.out.println("Semaphore acquired");
            sleep(5);
        } else {
            System.out.println("Could not acquire semaphore");
        }
    } catch (InterruptedException e) {
        throw new IllegalStateException(e);
    } finally {
        if (permit) {
            semaphore.release();
        }
    }
}

IntStream.range(0, 10)
    .forEach(i -> executor.submit(longRunningTask));

stop(executor);

上例中,executor 可以并发执行10个任务,而我们使用了一个容量为5的信号量来限制最大并发访问为5。使用try/finally块来保证即使发生异常时候也可以正常释放信号量非常重要。

上例代码执行结果为:

Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Semaphore acquired
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore
Could not acquire semaphore

信号量限制了模拟的耗时任务的最大并发为5。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/14809.html

(0)
小半的头像小半

相关推荐

极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!