Java多线程 - 同步synchronized与ReentrantLock(一)

在多线程的应用中,两个或者两个以上的线程需要共享同一个资源。如果多个线程同时在访问同一资源时,都做出了相应的操作来获取自己想要的资源,在这种情况下很难保证获取资源的准确性和唯一性,相互之间产生冲突,通常称之为竞争条件。

关于竞争条件的理解:比如火车买票,火车票(数量、座位号等等)是一定的,但卖火车票的窗口是不确定的到处都有,每个窗口就相当于一个线程,这么多的线程共用所有的火车票这个资源;如果在某一个时间点上,多个线程同时使用这个资源,那取出来的火车票是一样的,这样就会给乘客造成麻烦。

代码如下:

public class Ticket implements Runnable{
	
	//初始车票数共10张(共享资源)
	int ticketNum=10;
	
	@Override
	public void run() {
		//当车票数还有余票,进行买票操作
		if (ticketNum>0) {
			try {
				Thread.sleep(10);
				//输出线程名称(相对于买票人),并计算余票
				System.out.println("currentName: "+Thread.currentThread().getName()
						+"     余票: "+ --ticketNum);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			
		}
		
	}

}
public class OutTicket {

	public static void main(String[] args) {
		//创建共享资源
		Ticket ticket = new Ticket();
		//创建模拟买票人并开启买票即线程
		Thread thread1 = new Thread(ticket, "老一");
		Thread thread2 = new Thread(ticket, "老二");
		Thread thread3 = new Thread(ticket, "老三");
		Thread thread4 = new Thread(ticket, "老四");
		thread1.start();
		thread2.start();
		thread3.start();
		thread4.start();
	}

}

输出结果(某次):

currentName: 老四     余票: 8
currentName: 老二     余票: 9
currentName: 老一     余票: 7
currentName: 老三     余票: 9

正常情况下,四个人去买票,余票应该会逐个-1,而不会出现余票相同的情况。这就是多线程同时访问同一资源,造成的数据不准确问题。

为了解决上面的问题,需要引入线程同步的方式实现,当一个线程(人)要使用火车票这个资源时,我们就交给它一把锁,等它把整个买票流程走完之后再把锁给另外一个要用这个资源的线程,反复如此……这样就可以确保数据的准确性和唯一性。

Java中共有两种锁,可以实现线程同步问题,分别是synchronized和ReentrantLock。

synchronized关键字

synchronized简介

  1. synchronized实现同步的基础:java中每个对象都可以作为锁对象。当线程试图访问同步代码时,必须先获得对象锁,退出或抛出异常时必须释放锁,否则线程会一直处于阻塞状态。
  2. synchronized实现同步的表现形式分为两种:同步代码块和同步方法。

synchronized原理

同步代码块:任何一个对象都有一个监视器(Monitor)与之关联,线程执行监视器指令时,会尝试获取对象对应的监视器的所有权,即尝试获得对象的锁。

同步方法:使用synchronized关键字修饰的方法,称之为同步方法。

两者的本质都是对一个对象的监视器的获取。任意一个对象都拥有自己的监视器,当同步代码块或同步方法时,执行方法的线程必须先获取该对象的监视器才能进入同步代码块或同步方法,没有获取到监视器的线程将会被阻塞,并进入同步队列,线程状态变为阻塞状态。当成功获取监视器的线程释放了锁后,会唤醒在阻塞同步队列的线程,使其重新尝试对监视器的获取。

synchronized特点

  1. 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器
  2. 当前线程在同步代码块、同步方法中遇到break、return终止了该代码块、该方法的继续执行,当前线程将会释放同步监视器
  3. 当前线程在同步代码块或同步方法中出现了未处理的Error或Exception,导致了代码的异常终止,此时线程的同步监视器也会被释放
  4. 当前线程在执行同步代码块或同步方法时,执行了同步监视器对象的wait方法,导致当前线程的停止,此时也会释放同步监视器
  • 在执行同步方法或同步代码块,调用了Thread.sleep()、yield()方法来暂停线程,此时线程不会释放同步监视器

针对上面买票的问题(线程同步问题),可以使用同步代码块或同步方法的进行解决:

同步代码块:

public class Ticket implements Runnable{
	
	//初始车票数共10张(共享资源)
	int ticketNum=10;
	
	@Override
	public void run() {
		synchronized (this) {
			//当车票数还有余票,进行买票操作
			if (ticketNum>0) {
				try {
					Thread.sleep(10);
					//输出线程名称(相对于买票人),并计算余票
					System.out.println("currentName: "+Thread.currentThread().getName()
							+"     余票: "+ --ticketNum);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				
			}
		}
		
	}

}

或同步方法:

public class Ticket implements Runnable{
	
	//初始车票数共10张(共享资源)
	int ticketNum=10;
	
	@Override
	public synchronized void run() {
		
			//当车票数还有余票,进行买票操作
			if (ticketNum>0) {
				try {
					Thread.sleep(10);
					//输出线程名称(相对于买票人),并计算余票
					System.out.println("currentName: "+Thread.currentThread().getName()
							+"     余票: "+ --ticketNum);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				
			}
		
	}

}

输出结果:

currentName: 老三     余票: 9
currentName: 老四     余票: 8
currentName: 老一     余票: 7
currentName: 老二     余票: 6

从结果上可以看出,使用了同步代码块或者同步方法后,没有出现余票重复的问题,同时保证了余票在上一次的基础上逐个-1,保证数据的准确性和唯一性,解决下线程同步的问题。

ReentrantLock锁

在java 5之前,一直靠synchronized关键字来实现锁功能的,处理多线程并发的问题;而在java 5之后新增了Lock接口来实现锁的功能,同时也Lock接口提供ReentrantLock实现类(可重入锁)。

与synchronized关键字相比,ReentrantLock使用时需要显式的获取或释放锁,而synchronized可以隐式获取和释放锁,也就是说,在正常使用情况下,ReentrantLock需要手动操作锁的获取和释放,synchronized可以自动的获取和释放,从操作性上synchronized是相对便捷的,居然ReentrantLock是手动的,那么也有它的优势,就是可以自定义一些其他的操作,比如中断锁的获取及超时获取锁等多种特性。

下面是关于Lock接口一些主要方法:

void lock(): 执行此方法时,如果锁处于空闲状态,当前线程将获取到锁。相反,如果锁已经被其他线程持有,将禁用当前线程,直到当前线程获取到锁。
boolean tryLock() 如果锁可用,则获取锁,并立即返回true,否则返回false. 该方法和lock()的区别在于,tryLock()只是"试图"获取锁,如果锁不可用,不会导致当前线程被禁用,当前线程仍然继续往下执行代码。而lock()方法则是一定要获取到锁,如果锁不可用,就一直等待,在未获得锁之前,当前线程并不继续向下执行. 通常采用如下的代码形式调用tryLock()方法:
void unlock() 执行此方法时,当前线程将释放持有的锁. 锁只能由持有者释放,如果线程并不持有锁,却执行该方法,可能导致异常的发生.
Condition newCondition() 条件对象,获取等待通知组件。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的await()方法,而调用后,当前线程将释放锁。

ReentrantLock的使用

ReentrantLock的使用并不复杂,通常是加锁(获取锁)、释放同步锁即可

public class ReentrantLockTest {

	//定义锁对象
	private ReentrantLock lock=new ReentrantLock();
	//定义需要保证的线程安全的方法
	public void method1(){
		//获取锁,加锁
		lock.lock();
		try {
			//需要保证线程安全的代码
		} finally{
			//使用finally来保证锁的释放
			lock.unlock();
		}
	}

}

这一结构确保任何时刻只有一个线程进入临界区(临界区是指共享资源的代码区),一旦一个线程封锁了锁对象,其他任何线程都无法通过lock语句。当其他线程调用lock时,它们则被阻塞直到第一个线程释放锁对象。把解锁的操作放在finally中是十分必要的,如果在临界区发生了异常,锁是必须要释放的,否则其他线程将会永远阻塞。

使用ReentrantLock锁解决线程同步问题

针对上面买票的问题,下面可重入锁(ReentrantLock)解决

public class Ticket implements Runnable{
	
	//初始车票数共10张(共享资源)
	int ticketNum=10;

	private ReentrantLock lock=new ReentrantLock();
	@Override
	public void run() {
			
			//当车票数还有余票,进行买票操作
			if (ticketNum>0) {
				try {
					Thread.sleep(10);
					lock.lock();
					try{
						//输出线程名称(相对于买票人),并计算余票
						System.out.println("currentName: "+Thread.currentThread().getName()
								+"     余票: "+ --ticketNum);
					}finally{
						lock.unlock();
					}
					
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				
			}
		
	}

}

重入锁

重入锁,是指同一线程外层函数在获取锁之后,可以在外层内调用其他函数(称之为内层递归函数)也可再次获取该锁对象,是不受影响的。简单点就是,已经有锁对象可以再次获取自己的内部锁。

java内置锁synchronized和ReentrantLock都是可重入锁。

public class SynchronizedTest {
    public void method1() {
        synchronized (SynchronizedTest.class) {
            System.out.println("方法1获得ReentrantTest的锁运行了");
            method2();
        }
    }
    public void method2() {
        synchronized (SynchronizedTest.class) {
            System.out.println("方法1里面调用的方法2重入锁,也正常运行了");
        }
    }
    public static void main(String[] args) {
        new SynchronizedTest().method1();
    }
}
上面是synchronized的重入锁特性,在调用了method1方法时,已经获取到SynchronizeTest对象锁,如果此时在method1方法内部调用method2方法时,由于method1方法本身已经具有了该锁了,可以再次获取。
public class ReentrantLockTest {
    private Lock lock = new ReentrantLock();
    public void method1() {
        lock.lock();
        try {
            System.out.println("方法1获得ReentrantLock锁运行了");
            method2();
        } finally {
            lock.unlock();
        }
    }
    public void method2() {
        lock.lock();
        try {
            System.out.println("方法1里面调用的方法2重入ReentrantLock锁,也正常运行了");
        } finally {
            lock.unlock();
        }
    }
    public static void main(String[] args) {
        new ReentrantLockTest().method1();
    }
}
上面是ReentrantLock的重入特性,和synchronized是一样的。

公平锁

CPU在调度线程资源时是在等待线程队列里随机挑选一个线程,由于这种随机性所以无法保证线程的先到先得的特点(synchronized控制的锁就是这种非公平锁)。这种非公平现象,有可能造成一些线程(优先级低的线程)都无法获取CPU资源的执行权,而优先级高的线程会不断加强自己执行资源。要解决这种饥饿非公平问题,需要引入公平锁。

公平锁:可以保证线程的执行先后顺序,可以避免非公平现象的产生,但效率会比较低,因为要按顺序执行,需要维护一个有序队列。

公平锁的实现,只需在ReentrantLock的构造函数传入true即可,false则是非公平锁,无参构造函数默认是false

    /**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

上面买票过程可以看出是无序的,从调用顺序应该从老一到老五是依次的,下面使用公平锁实现顺序效果:

public class Ticket implements Runnable {

	private ReentrantLock lock = new ReentrantLock(true);

	@Override
	public void run() {

		// 当车票数还有余票,进行买票操作
		if (ticketNum > 0) {

			lock.lock();
			try {
				// 输出线程名称(相对于买票人),并计算余票
				System.out.println("currentName: "
						+ Thread.currentThread().getName() + "     余票: "
						+ --ticketNum);
			} finally {
				lock.unlock();
			}

		}

	}

}

输出结果:

currentName: 老一     余票: 9
currentName: 老二     余票: 8
currentName: 老三     余票: 7
currentName: 老四     余票: 6

synchronized和ReentrantLock的比较

  1. Lock一个接口,提供ReentrantLock实现类,而synchronized是个关键字,是java内置线程同步。
  2. synchronized在发生异常时,会自动的释放线程占用锁对象,不会导致死锁的现象发生,而Lock在发生异常时,如果没有主动的通过unLock方法释放锁对象,则可能会造成死锁的发生,因此在是使用Lock时需要在finally块中释放锁。
  3. Lock可以让等待锁的线程中断,而synchronized则不行,会一直等待下去,直到有唤醒的操作。
  4. Lock可以判断线程是否成功获取锁对象,而synchronized则不行。

总结:ReentrantLock和synchronized相比,主要是ReentrantLock实现类定义了一些特殊的方法,从而决定了ReentrantLock在功能上比synchronized更丰富些。但缺点也是,比如上述第二点,可以反映出在一定程度上synchronized安全性和使用便捷性上好些。

ReentrantLock中的一些方法:

isFair()      //判断锁是否是公平锁

isLocked()    //判断锁是否被任何线程获取了

isHeldByCurrentThread()   //判断锁是否被当前线程获取了

hasQueuedThreads()   //判断是否有线程在等待该锁

性能比较

在性能上来说,如果竞争资源不激烈,两者的性能是差不多的,而 当竞争资源非常激烈时(即有大量线程同时竞争),此时ReentrantLock的性能要远远优于synchronized 。所以说,在具体使用时要根据适当情况选择。

在JDK1.5中,synchronized是性能低效的。因为这是一个重量级操作,它对性能最大的影响是阻塞的是实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性带来了很大的压力。相比之下使用Java提供的ReentrankLock对象,性能更高一些。到了JDK1.6,发生了变化,对synchronize加入了很多优化措施,有自适应自旋,锁消除,锁粗化,轻量级锁,偏向锁等等。导致在JDK1.6上synchronize的性能并不比Lock差。官方也表示,他们也更支持synchronize,在未来的版本中还有优化余地,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步

Java多线程之同步(二)

猜你喜欢

转载自blog.csdn.net/hzw2017/article/details/80628334