【并发编程】线程锁--Synchronized、ReentrantLock(可重入锁)

在说锁之前,我们要明白为什么要加锁,不加锁会怎样?

在并发编程中,很容易出现线程安全问题,接下来我们看个很经典的例子--银行取钱,来看一下有关线程安全的问题。

取钱的流程可以分为一下几个步骤:

  • 1.用户输入账户,密码,系统判断用户的账户,密码是否正确。
  • 2.用户输入取款金额
  • 3.系统判断账户余额是否大于取款金额
  • 4.如果余额大于取款金额,取款成功;小于取款金额,取款失败。

假设现在有一账户,内有1000元,两人同时取钱,看下面用代码模拟的情况。

public class Account {
    //封装账户编号,账户余额的两个成员变量
    private String accountNo;
    private double balance;

    public Account() {
    }

    public String getAccountNo() {
        return accountNo;
    }

    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }
}

 

public class DrawThread extends Thread {
    //模拟用户账户
    private Account account;
    //当前取钱线程所希望取钱的钱数
    private double drawAmount;

    public DrawThread(String name, Account account, double drawAmount) {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }

    //当多个线程修改同一共享数据时,将涉及到数据安全问题
    public void run() {

            //账户余额大于取钱数目
            if (account.getBalance() >= drawAmount) {
                //吐出钞票
                System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);

                try {
                    Thread.sleep(1);
                } catch (InterruptedException ex) {
                    ex.printStackTrace();
                }

                //修改余额
                account.setBalance(account.getBalance() - drawAmount);
                System.out.println("\t 余额为:" + account.getBalance());

            } else {
                System.out.println(getName() + "取钱失败!余额不足!");
            }

    }
}
public class DrawThread extends Thread {
    //模拟用户账户
    private Account account;
    //当前取钱线程所希望取钱的钱数
    private double drawAmount;

    public DrawThread(String name, Account account, double drawAmount) {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }

    //当多个线程修改同一共享数据时,将涉及到数据安全问题
    public void run() {
        //使用account作为同步监视器,任何线程进入下面同步代码块之前
        //必须先获得account账户的锁定--其他线程无法获得锁,也就无法修改它
        //这种做法符合:"加锁-->修改-->释放锁"的逻辑
        synchronized (account) {
            //账户余额大于取钱数目
            if (account.getBalance() >= drawAmount) {
                //吐出钞票
                System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);

                try {
                    Thread.sleep(1);
                } catch (InterruptedException ex) {
                    ex.printStackTrace();
                }

                //修改余额
                account.setBalance(account.getBalance() - drawAmount);
                System.out.println("\t 余额为:" + account.getBalance());

            } else {
                System.out.println(getName() + "取钱失败!余额不足!");
            }
        }
        //同步代码块结束,该线程释放同步锁
    }
}
public class DrawTest {
    public static void main(String[] args) {
        //创建一个庄户
        Account acct = new Account("123456",1000);
        //模拟两个线程对同一账户取钱
        new DrawThread("甲",acct, 800).start();
        new DrawThread("乙",acct,800).start();
    }
}

 多次运行我们发现结果不太对,只有1000元,却取出来1600元。

看上去这个流程没有任何问题,但是这个流程在多线程的情况下,就有可能出现问题,注意是可能。也许你的程序运行了一百万次不出问题,但是不出问题不等于没有问题。那么问题出在哪里呢,如果我们账户余额有1000元,假设两个人同时对这个账户取钱800元,系统同时判断是否够1000元,如果是同时,那么两次判断显然是对的,就会取走1600元,这就是编程安全,我们需要让不安全的地方同步执行,一个执行完毕,另一个在执行,这样就能避免上述的问题。而解决这一问题的办法就是让异步变成同步,最简单的方式就是加锁。下面我们开始介绍几种常见的。

Synchronized 

简介

synchronized 关键字,代表这个方法加锁,相当于不管哪一个线程(例如线程A),运行到这个方法时,都要检查有没有其它线程B(或者C、 D等)正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程B(或者C 、D)运行完这个方法后再运行此线程A,没有的话,锁定调用者,然后直接运行。可以确保线程互斥的访问同步代码。它包括两种用法:synchronized 方法和 synchronized 块。

Java语言的关键字,可用来给对象和方法或者代码块加锁,当它锁定一个方法或者一个代码块的时候,同一时刻最多只有一个线程执行这段代码。当两个并发线程访问同一个对象object中的这个加锁同步代码块时,一个时间内只能有一个线程得到执行。另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。然而,当一个线程访问object的一个加锁代码块时,另一个线程仍可以访问该object中的非加锁代码块。

使用

1.同步代码块

锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

    public void eat() {
        //synchronized 包括的即为同步代码块,括号后的obj就是同步监视器,当同步代码块执行完毕
        //改线程释放对同步监视器的锁定
        synchronized (obj) {
            .......
            .......
        }
    }

举例:对DrawThread中取钱的代码进行加锁,让异步变成同步。这样虽然是看起来是同时取钱,但是取钱的过程就是异步的。

public class DrawThread extends Thread {
    //模拟用户账户
    private Account account;
    //当前取钱线程所希望取钱的钱数
    private double drawAmount;

    public DrawThread(String name, Account account, double drawAmount) {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }

    //当多个线程修改同一共享数据时,将涉及到数据安全问题
    public void run() {
        //使用account作为同步监视器,任何线程进入下面同步代码块之前
        //必须先获得account账户的锁定--其他线程无法获得锁,也就无法修改它
        //这种做法符合:"加锁-->修改-->释放锁"的逻辑
        synchronized (account) {
            //账户余额大于取钱数目
            if (account.getBalance() >= drawAmount) {
                //吐出钞票
                System.out.println(getName() + "取钱成功!吐出钞票:" + drawAmount);

                try {
                    Thread.sleep(1);
                } catch (InterruptedException ex) {
                    ex.printStackTrace();
                }

                //修改余额
                account.setBalance(account.getBalance() - drawAmount);
                System.out.println("\t 余额为:" + account.getBalance());

            } else {
                System.out.println(getName() + "取钱失败!余额不足!");
            }
        }
        //同步代码块结束,该线程释放同步锁
    }
}

无论运行多少次,都是线程安全的

 

总结:如果有两个线程同时对同一个对象进行修改时,只有一个线程能够抢到锁。一个线程得到锁后,其他线程无法获得锁,也就不能访问synchronized修饰的实例方法,其他方法还可以访问。      

2.同步方法

普通同步方法(实例方法):锁是当前实例对象 ,进入同步代码前要获得当前实例的锁

public synchronized void eat(){
    .......
    .......
}

同步方法的锁对象就是 this,this为类的 Class 对象。这和下面代码把方法中代码全部用 synchronized(this) 括起来的效果是一样的: 

public void eat(){
	synchronized(this){
	    .......
  	    .......
	}
}

银行取钱问题中,之所以出现线程不安全问题,本质上还是同时修改了余额,所以我们把setbalance设置成线程安全的即可。

改造如下:为account对象提供给一个安全的修改balance方法。

public class Account {
    //封装账户编号,账户余额的两个成员变量
    private String accountNo;
    private double balance;

    public Account() {
    }

    public String getAccountNo() {
        return accountNo;
    }

    public void setAccountNo(String accountNo) {
        this.accountNo = accountNo;
    }

    public double getBalance() {
        return balance;
    }

    public void setBalance(double balance) {
        this.balance = balance;
    }

    public Account(String accountNo, double balance) {
        this.accountNo = accountNo;
        this.balance = balance;
    }

    //提供一个线程安全的draw()方法来完成取钱操作
    public synchronized void draw(double drawAmount){
        //账户余额大于取钱数目
        if (balance >= drawAmount) {
            //吐出钞票
            System.out.println(Thread.currentThread().getName() + "取钱成功!吐出钞票:" + drawAmount);

            try {
                Thread.sleep(1);
            } catch (InterruptedException ex) {
                ex.printStackTrace();
            }

            //修改余额
            balance -= drawAmount;
            System.out.println("\t 余额为:" + balance);

        } else {
            System.out.println(Thread.currentThread().getName() + "取钱失败!余额不足!");
        }
    }
}

 

public class DrawThread extends Thread {
    //模拟用户账户
    private Account account;
    //当前取钱线程所希望取钱的钱数
    private double drawAmount;

    public DrawThread(String name, Account account, double drawAmount) {
        super(name);
        this.account = account;
        this.drawAmount = drawAmount;
    }

    //当多个线程修改同一共享数据时,将涉及到数据安全问题
    public void run() {
       //直接调用account对象的draw方法来执行取钱操作
       // 同步方法的同步监视器是this,this代表调用draw方法的对象
       //也就是说,线程进入draw方法之前,必须先对account对象加锁
       account.draw(drawAmount);
    }
}

 这样也就是线程安全的了。

静态同步方法:锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁

public class synchronizedTest implements Runnable {
    //共享资源
    static int i =0;
    /**
     * synchronized 修饰实例方法
     */
    public static synchronized void increase(){
        i++;
    }
    @Override
    public void run(){
        for (int j =0 ; j<10000;j++){
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(new synchronizedTest());
        Thread t2 = new Thread(new synchronizedTest());
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println(i);
    }

运行结果如图

两个线程实例化两个不同的对象,但是访问的方法是静态的,两个线程发生了互斥(即一个线程访问,另一个线程只能等着),因为静态方法是依附于类而不是对象的,当synchronized修饰静态方法时,锁是class对象。

下面是 synchronized 的使用总结:

  • 1、选用一个锁对象,可以是任意对象;
  • 2、锁对象锁的是同步代码块,并不是自己;
  • 3、不同类型的多个 Thread 如果有代码要同步执行,锁对象要使用所有线程共同持有的同一个对象;
  • 4、需要同步的代码放到大括号中。需要同步的意思就是需要保证原子性、可见性、有序性中的任何一种或多种。不要放不需要同步的代码进来,影响代码效率。

有关synchronized的参考文章:https://blog.csdn.net/zjy15203167987/article/details/82531772

ReentrantLock

刚学习了 Java 的内置锁,也就是 synchronized 关键字的使用。在 Java 5.0 之前只有 synchronized 和 volatile 可以用来进行同步。在 Java 5.0 之后,出现了新的同步机制,也就是使用 ReentrantLock 显式的加锁。

有了synchronized之后为什么还要有ReentrantLock呢?因为使用ReentrantLock不仅可以和synchronized 一样实现独占锁的功能。而且ReentrantLock相比synchronized而言功能更加丰富,使用起来更为灵活,也更适合复杂的并发场景。

简单使用

//声明可重入锁
Lock lock = new ReentrantLock();
//加锁,在lock和unlock之间的代码会同步执行
lock.lock();
try {
 doSomething();
}finally {
//必须释放锁,程序不会自动释放
 lock.unlock();
}

公平锁和非公平锁

synchronized 是非公平锁,也就是说每当锁匙放的时候,所有等待锁的线程并不会按照排队顺去依次获得锁,而是会再次去争抢锁。ReentrantLock 相比较而言更为灵活,它能够支持公平和非公平锁两种形式。只需要在声明的时候传入 true。

Lock lock = new ReentrantLock(true);

默认无参则是非公平锁。 

 trylock

前面我们通过 lock.lock (); 来完成加锁,此时加锁操作是阻塞的,直到获取锁才会继续向下进行。ReentrantLock 其实还有更为灵活的枷锁方式 tryLock。tryLock 方法有两个重载,第一个是无参数的 tryLock 方法,被调用后,该方法会立即返回获取锁的情况。获取为 true,未能获取为 false。我们的代码中可以通过返回的结果进行进一步的处理。第二个是有参数的 tryLock 方法,通过传入时间和单位,来控制等待获取锁的时长。如果超过时间未能获取锁则放回 false,反之返回 true。

举例如下:

if(lock.tryLock(2, TimeUnit.SECONDS)){
   try {
      doSomething();
   } catch (InterruptedException e) {
      e.printStackTrace();
   }finally {
      lock.unlock();
   }
}else{
  doSomethingElse();
}

synchronized和ReentrantLock 的区别

参考博客:https://mp.weixin.qq.com/s/cdHfTTvMpH60SwG2bjTMBw

两者都是可重入锁

两者都是可重入锁。“可重入锁”概念是:自己可以再次获取自己的内部锁。比如一个线程获得了某个对象的锁,此时这个对象锁还没有释放,当其再次想要获取这个对象的锁的时候还是可以获取的,如果不可锁重入的话,就会造成死锁。同一个线程每次获取锁,锁的计数器都自增1,所以要等到锁的计数器下降为0时才能释放锁。

synchronized 依赖于 JVM 而 ReentrantLock 依赖于 API

synchronized 是依赖于 JVM 实现的,前面我们也讲到了 虚拟机团队在 JDK1.6 为 synchronized 关键字进行了很多优化,但是这些优化都是在虚拟机层面实现的,并没有直接暴露给我们。ReentrantLock 是 JDK 层面实现的(也就是 API 层面,需要 lock() 和 unlock() 方法配合 try/finally 语句块来完成),所以我们可以通过查看它的源代码,来看它是如何实现的。

 ReentrantLock 比 synchronized 增加了一些高级功能

相比synchronized,ReentrantLock增加了一些高级功能。主要来说主要有三点:①等待可中断;②可实现公平锁;③可实现选择性通知(锁可以绑定多个条件)

  • ReentrantLock提供了一种能够中断等待锁的线程的机制,通过lock.lockInterruptibly()来实现这个机制。也就是说正在等待的线程可以选择放弃等待,改为处理其他事情。

  • ReentrantLock可以指定是公平锁还是非公平锁。而synchronized只能是非公平锁。所谓的公平锁就是先等待的线程先获得锁。 ReentrantLock默认情况是非公平的,可以通过 ReentrantLock类的ReentrantLock(boolean fair)构造方法来制定是否是公平的。

  • synchronized关键字与wait()和notify()/notifyAll()方法相结合可以实现等待/通知机制,ReentrantLock类当然也可以实现,但是需要借助于Condition接口与newCondition() 方法。Condition是JDK1.5之后才有的,它具有很好的灵活性,比如可以实现多路通知功能也就是在一个Lock对象中可以创建多个Condition实例(即对象监视器),线程对象可以注册在指定的Condition中,从而可以有选择性的进行线程通知,在调度线程上更加灵活。在使用notify()/notifyAll()方法进行通知时,被通知的线程是由 JVM 选择的,用ReentrantLock类结合Condition实例可以实现“选择性通知” ,这个功能非常重要,而且是Condition接口默认提供的。而synchronized关键字就相当于整个Lock对象中只有一个Condition实例,所有的线程都注册在它一个身上。如果执行notifyAll()方法的话就会通知所有处于等待状态的线程这样会造成很大的效率问题,而Condition实例的signalAll()方法 只会唤醒注册在该Condition实例中的所有等待线程。

如果你想使用上述功能,那么选择ReentrantLock是一个不错的选择。

如果只是简单的同步,不必要使用ReentrantLock,使用Synchronized就够了,毕竟后者不用显示声明锁,但是前者如果忘记释放锁,会出大问题。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值