java并发之线程同步(synchronized和锁机制)

时间:2021-07-25 13:06:39

多个执行线程共享一个资源的情景,是并发编程中最常见的情景之一。多个线程读或者写相同的数据等情况时可能会导致数据不一致。为了解决这些问题,引入了临界区概念。临界区是一个用以访问共享资源的代码块,这个代码块在同一时间内只允许一个线程执行。

Java提供了同步机制。当一个线程试图访问一个临界区时,它将使用一种同步机制来查看是不是已有其他线程进入临界区。如果没有其他线程进入临界区,它就可以进入临界区;如果已有线程进入了临界区,它就被同步机制挂起,直到进入的线程离开这个临界区。如果在等待进入临界区的线程不止一个,JVM会随机选择其中的一个,其余的将继续等待。 概念比较好理解,具体在java程序中是如何体现的呢?临界区对应的代码是怎么样的?

使用synchronized实现同步方法

每一个用synchronized关键字声明的方法都是临界区。在Java中,同一个对象的临界区,在同一时间只有一个允许被访问。 注意:用synchronized关键字声明的静态方法,同时只能被一个执行线程访问,但是其他线程可以访问这个对象的非静态方法。即:两个线程可以同时访问一个对象的两个不同的synchronized方法,其中一个是静态方法,一个是非静态方法。 知道了synchronized关键字的作用,再来看一下synchronized关键字的使用方式。
  • 在方法声明中加入synchronized关键字
  • 1 public synchronized void addAmount(double amount) {
    2 }
  • 在代码块中使用synchronized关键字,obj一般可以使用this关键字表示本类对象
  • 1 synchronized(obj){
    2 }
需要注意的是:前面已经提到,引入synchronized关键字是为了声明临界区,解决在多线程环境下共享变量的数据更改安全问题。那么,一般用到synchronized关键字的地方也就是 在对共享数据 访问或者修改的地方。下面举一个例子,例子场景是这样:公司定时会给账户打款,银行对账户进行扣款。那么款项对于银行和公司来说就是一个共享数据。那么synchronized关键字就应该在修改账户的地方使用。
声明一个Account类:
 1 public class Account {
2 private double balance;
3 public double getBalance() {
4 return balance;
5 }
6 public void setBalance(double balance) {
7 this.balance = balance;
8 }
9 public synchronized void addAmount(double amount) {
10 double tmp=balance;
11 try {
12 Thread.sleep(10);
13 } catch (InterruptedException e) {
14 e.printStackTrace();
15 }
16 tmp+=amount;
17 balance=tmp;
18 }
19 public synchronized void subtractAmount(double amount) {
20 double tmp=balance;
21 try {
22 Thread.sleep(10);
23 } catch (InterruptedException e) {
24 e.printStackTrace();
25 }
26 tmp-=amount;
27 balance=tmp;
28 }
29 }
Bank类扣款:
 1 public class Bank implements Runnable {
2 private Account account;
3 public Bank(Account account) {
4 this.account=account;
5 }
6 public void run() {
7 for (int i=0; i<100; i++){
8 account.subtractAmount(1000);
9 }
10 }
11 }
Company类打款:
 1 public class Company implements Runnable {
2 private Account account;
3 public Company(Account account) {
4 this.account=account;
5 }
6
7 public void run() {
8 for (int i=0; i<100; i++){
9 account.addAmount(1000);
10 }
11 }
12 }
这里需要注意的就是:在Bank和Company的构造函数里面传递的参数是Account,就是一个共享数据。 Main函数:
 1 public class Main {
2 public static void main(String[] args) {
3 Account account=new Account();
4 account.setBalance(1000);
5 Company company=new Company(account);
6 Thread companyThread=new Thread(company);
7 Bank bank=new Bank(account);
8 Thread bankThread=new Thread(bank);
9
10 companyThread.start();
11 bankThread.start();
12 try {
13 companyThread.join();
14 bankThread.join();
15 System.out.printf("Account : Final Balance: %f\n",account.getBalance());
16 } catch (InterruptedException e) {
17 e.printStackTrace();
18 }
19 }
20 }
这个例子比较简单,但是可以说明问题。 补充: 1、synchronized关键字会降低应用程序的性能,因此只能在并发场景中修改共享数据的方法上使用它。 2、临界区的访问应该尽可能的短。方法的其余部分保持在synchronized代码块之外,以获取更好的性能

使用非依赖属性实现同步

非依赖属性:例如在一个类中有两个非依赖属性,Object obj1,Object obj2;他们被多个线程共享,那么同一时间只允许一个线程访问其中的一个属性变量,其他的某个线程访问另一个属性变量。 举例如下:两个看电影的房间和两个售票口,一个售票处卖出的一张票,只能用于其中的一个电影院。不能同时作用于两个电影房间。 Cinema类:
 1 public class Cinema {
2 private long vacanciesCinema1;
3 private long vacanciesCinema2;
4
5 private final Object controlCinema1, controlCinema2;
6
7 public Cinema(){
8 controlCinema1=new Object();
9 controlCinema2=new Object();
10 vacanciesCinema1=20;
11 vacanciesCinema2=20;
12 }
13
14 public boolean sellTickets1 (int number) {
15 synchronized (controlCinema1) {
16 if (number<vacanciesCinema1) {
17 vacanciesCinema1-=number;
18 return true;
19 } else {
20 return false;
21 }
22 }
23 }
24
25 public boolean sellTickets2 (int number){
26 synchronized (controlCinema2) {
27 if (number<vacanciesCinema2) {
28 vacanciesCinema2-=number;
29 return true;
30 } else {
31 return false;
32 }
33 }
34 }
35
36 public boolean returnTickets1 (int number) {
37 synchronized (controlCinema1) {
38 vacanciesCinema1+=number;
39 return true;
40 }
41 }
42 public boolean returnTickets2 (int number) {
43 synchronized (controlCinema2) {
44 vacanciesCinema2+=number;
45 return true;
46 }
47 }
48 public long getVacanciesCinema1() {
49 return vacanciesCinema1;
50 }
51 public long getVacanciesCinema2() {
52 return vacanciesCinema2;
53 }
54 }
这样的话,vacanciescinema1和vacanciescinema2(剩余票数)是独立的,因为他们属于不同的对象。这种情况下,只允许一个同时有一个线程修改vacanciescinema1或者vacanciescinema2,但是允许有两个线程同时修改vacanciescinema1和vacanciescinema2。

在同步块中使用条件(wait(),notify(),notifyAll())

首先需要明确:
  1. 上述三个方法都是Object 类的方法。
  2. 上述三个方法都必须在同步代码块中使用。
当一个线程调用wait()方法时,JVM将这个线程置入休眠,并且释放控制这个同步代码块的对象,同时允许其他线程执行这个对象控制的其他同步代码块。为了唤醒这个线程,必须在这个对象控制的某个同步代码块中调用notify()或者notifyAll()方法。
上述一段话很重要!!!它说明了使用上述三个函数的方法以及方法的作用。   wait():将线程置入休眠状态,并且释放控制这个同步代码块的对象,释放了以后其他线程就可以执行这个对象控制的其他代码块。也就是可以进入了。这个和Thread.sleep(millions)方法不同,sleep()方法是睡眠指定时间后自动唤醒。 notify()/notifyAll():使用wait()方法休眠的线程需要在该对象控制的某个同步代码块中 调用notify或者notifyAll()方法去唤醒,才能进入就绪状态等待JVM的调用。否则一致处于休眠状态。 难点:线程休眠和唤醒的时机,就是说什么时候调用notify()或者notifyAll()方法??? 拿生产者和消费者的例子来说:生产者往队列中塞数据,消费者从队列中取数据,所以这个队列是共享数据 数据存储类 EventStorage 塞数据方法和取数据方法:set()、get()
 1 public synchronized void set(){
2 while (storage.size()==maxSize){
3 try {
4 wait();
5 } catch (InterruptedException e) {
6 e.printStackTrace();
7 }
8 }
9 storage.add(new Date());
10 System.out.printf("Set: %d\n", storage.size());
11 notify();
12 }
13 public synchronized void get(){
14 while (storage.size()==0){
15 try {
16 wait();
17 } catch (InterruptedException e) {
18 e.printStackTrace();
19 }
20 }
21 System.out.printf("Get: %d: %s\n",storage.size(),((LinkedList<?>)storage).poll());
22 notify();
23 }
  分析上面这个简单的程序:1、方法使用synchronized关键字声明同步代码块。所以这个函数里面可以使用同步条件。 2、首先判断队列是否已经满了,这里要使用while而不是if。为什么呢?while是一致查询是否已经满了,而if是判断一次就完事了。 3、如果满了,调用wait()方法释放该对象,那么其他方法(例如get())就可以使用这个对象了。get()方法进入后取出一个数据,然后唤醒上一个被休眠的线程。 4、虽然线程被唤醒了,但是由于get()方法线程占用对象锁,所以set()方法处于阻塞状态。直到get()方法取出所有的数据满足休眠条件以后,set()方法重新执行 5、重复以上步骤

使用锁实现同步

Java提供了同步代码块的另一种机制,它比synchronized关键字更强大也更加灵活。这种机制基于Lock接口及其实现类(例如:ReentrantLock) 它比synchronized关键字好的地方: 1、提供了更多的功能。tryLock()方法的实现,这个方法试图获取锁,如果锁已经被其他线程占用,它将返回false并继续往下执行代码。 2、Lock接口允许分离读和写操作,允许多个线程读和只有一个写线程。ReentrantReadWriteLock 3、具有更好的性能 一个锁的使用实例:
 1 public class PrintQueue {
2 private final Lock queueLock=new ReentrantLock();
3
4 public void printJob(Object document){
5 queueLock.lock();
6
7 try {
8 Long duration=(long)(Math.random()*10000);
9 System.out.printf("%s: PrintQueue: Printing a Job during %d seconds\n",Thread.currentThread().getName(),(duration/1000));
10 Thread.sleep(duration);
11 } catch (InterruptedException e) {
12 e.printStackTrace();
13 } finally {
14 queueLock.unlock();
15 }
16 }
17 }
声明一把锁,其中ReentrantLock(可重入的互斥锁)是Lock接口的一个实现
1 private final Lock queueLock=new ReentrantLock();
然后在函数里面调用lock()方法声明同步代码块(临界区)
1 queueLock.lock();
最后在finally块中释放锁,重要!!!
1 queueLock.unlock();

使用读写锁实现同步数据访问

锁机制最大的改进之一就是ReadWriteLock接口和他的唯一实现类ReentrantReadWriteLock.这个类有两个锁,一个是读操作锁,一个是写操作锁。使用读操作锁时可以允许多个线程同时访问,使用写操作锁时只允许一个线程进行。在一个线程执行写操作时,其他线程不能够执行读操作。   在调用写操作锁时,使用一个线程。 写操作锁的用法:
1 public void setPrices(double price1, double price2) {
2 lock.writeLock().lock();
3 this.price1=price1;
4 this.price2=price2;
5 lock.writeLock().unlock();
6 }
读操作锁:
 1   public double getPrice1() {
2 lock.readLock().lock();
3 double value=price1;
4 lock.readLock().unlock();
5 return value;
6 }
7 public double getPrice2() {
8 lock.readLock().lock();
9 double value=price2;
10 lock.readLock().unlock();
11 return value;
12 }

修改锁的公平性

ReentrantLock和ReetrantReadWriteLock构造函数都含有一个布尔参数fair。默认fair为false,即非公平模式。
公平模式:当有很多线程在等待锁时,锁将选择一个等待时间最长的线程进入临界区。 非公平模式:当有很多线程在等待锁时,锁将随机选择一个等待区(就绪状态)的线程进入临界区。 这两种模式只适用于lock()和unlock()方。而Lock接口的tryLock()方法没有将线程置于休眠,fair属性并不影响这个方法。

在锁中使用多条件(Multri Condition)

锁条件可以和synchronized关键字声明的临界区的方法(wait(),notify(),notifyAll())做类比。锁条件通过Conditon接口声明。Condition提供了挂起线程和唤醒线程的机制。 使用方法:
 1 private Condition lines;
2 private Condition space;
3 */
4 public void insert(String line) {
5 lock.lock();
6 try {
7 while (buffer.size() == maxSize) {
8 space.await();
9 }
10 buffer.offer(line);
11 System.out.printf("%s: Inserted Line: %d\n", Thread.currentThread()
12 .getName(), buffer.size());
13 lines.signalAll();
14 } catch (InterruptedException e) {
15 e.printStackTrace();
16 } finally {
17 lock.unlock();
18 }
19 }
20 public String get() {
21 String line=null;
22 lock.lock();
23 try {
24 while ((buffer.size() == 0) &&(hasPendingLines())) {
25 lines.await();
26 }
27
28 if (hasPendingLines()) {
29 line = buffer.poll();
30 System.out.printf("%s: Line Readed: %d\n",Thread.currentThread().getName(),buffer.size());
31 space.signalAll();
32 }
33 } catch (InterruptedException e) {
34 e.printStackTrace();
35 } finally {
36 lock.unlock();
37 }
38 return line;
39 }