【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使用