多线程-synchronized、lock

1、什么时候会出现线程安全问题?

  在多线程编程中,可能出现多个线程同时访问同一个资源,可以是:变量、对象、文件、数据库表等。此时就存在一个问题:

  每个线程执行过程是不可控的,可能导致最终结果与实际期望结果不一致或者直接导致程序出错。

  如我们在第一篇博客中出现的count--的问题。这是一个典型的非线程安全问题。这一被多个线程访问的资源count变量被称为:临界资源(共享资源)。但当多个线程执行一个方法,方法内部的局部变量并不是临界资源,因为方法在栈上执行,java栈是线程私有的,因此不会产生线程安全问题


2、如何解决线程安全问题?

  基本上所有的并发模式解决线程安全问题,都采用序列化临界资源的方案,即同一时刻,只能有一个线程访问临界资源,也称作同步互斥访问。在访问临界资源的代码前加锁,当访问完临界资源后释放锁,让其他线程继续访问。java中提供了两种方式来实现同步互斥访问:synchronized和lock。


3、synchronized关键字详解

  synchronized的三种应用方式包括:

  a:修饰实例方法,作用于当前实例加锁,进入同步代码前要获得当前实例的锁

  b:修饰静态方法,作用于当前类对象加锁,进入同步代码前要获得当前类对象的锁

  c:修饰代码块,指定加锁对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁

  代码演示:实例对象锁就是synchronized修饰实例对象中的实例方法,实例方法不包括静态方法

public class ThreadDemo2 {
public static void main(String[] args) throws InterruptedException {
Test1 test1 = new Test1();
Thread t1 = new Thread(test1);
Thread t2 = new Thread(test1);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
class Test1 implements Runnable{
//临界资源
static int i=0; /**
* synchronized 修饰实例方法
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<100000;j++){
increase();
System.out.println(i);
}
}
}

  当synchronized修饰increase()方法后,i值的操作便是线程安全的。输出结果是200000,如果不加synchronized,结果可能小于这个值。当一个线程正在访问一个对象的synchronized实例方法,其他线程就不能访问该对象其他的synchronized方法。一个对象只有一把锁。当一个线程获取该对象的锁后,其他线程无法获取该对象的锁。但可以访问该对象的非synchronized方法。有一种特殊的情况,当两个线程访问的实例对象不同,则锁是不同的,当这两个线程操作数据并非共享的,线程安全是有保障的,但当操作数据是共享的,那么线程安全无法保证,演示 如下代码:

public class ThreadDemo2 {
public static void main(String[] args) throws InterruptedException {
Test1 test1 = new Test1();
Thread t1 = new Thread(test1);
Test1 test2 = new Test1();
Thread t2 = new Thread(test2);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
class Test1 implements Runnable{
//临界资源
static int i=0; /**
* synchronized 修饰实例方法 锁对象是实例对象
*/
public synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<100000;j++){
increase();
System.out.println(i);
}
}
}

  此demo运行结果也会出现值小于20000,我们创建了两个Test1的实例,启动两个不同的线程对共享变量i进行操作,虽然我们队increase方法添加了同步锁,但却new了两个不同实例,此时就存在两个实例对象锁,因此t1和t2都会进入各自的对象锁,因此无法保证线程安全。解决这种错误地方式就是将synchronized作用于静态的increase方法,这样的话,对象锁就是当前类对象,无论创建多少个实例对象,类对象只有一个,对象锁是唯一的。

public class ThreadDemo2 {
public static void main(String[] args) throws InterruptedException {
Test1 test1 = new Test1();
Thread t1 = new Thread(test1);
Test1 test2 = new Test1();
Thread t2 = new Thread(test2);
t1.start();
t2.start();
t1.join();
t2.join();
}
}
class Test1 implements Runnable{
//临界资源
static int i=0; /**
* synchronized 修饰静态方法,锁对象是类的class对象
*/
public static synchronized void increase(){
i++;
}
@Override
public void run() {
for(int j=0;j<100000;j++){
increase();
System.out.println(i);
}
}
}

  synchronized作用于静态方法时,锁是当前类的class对象锁。静态成员是类成员。通过class对象锁可以控制静态成员的并发操作。需要注意的是:如果一个线程调用一个实例对象的非static synchronized方法,而另一个线程调用这个实例对象所属类的静态synchronized方法,是允许的,不会发生互斥现象。因为访问静态synchronized方法占用的锁是当前类的class对象,而非访问静态synchronized方法占用的当前实例对象锁。锁对象不同,但我们需要意识到这种情况下可能发生线程安全问题,因为操作了共享资源。

  一些情况下,我们编写的方法体过大,同时存在一些比较耗时的操作。而需要同步的代码只有一小部分,我们可以通过synchronized代码块来对需要同步的代码进行包裹:

public class ThreadDemo2 implements Runnable{
static ThreadDemo2 test1 = new ThreadDemo2();
//临界资源
static int i=0;
@Override
public void run() {
//省略其他耗时操作....
//使用同步代码块对变量i进行同步操作,锁对象为test1
synchronized(test1){
for(int j=0;j<1000000;j++){
i++;
}
}
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(test1);
Thread t2 = new Thread(test1);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(i);
}
}

  当前情况下,将synchronized作用于一个给定的实例对象test1,即当前实例对象就是锁对象,除了test1作为对象外,我们还可以使用this对象(synchronized(this)代表当前实例)或当前类的class对象作为锁(synchronized(ThreadDemo2.class))。


synchronized的一些特性:

  在java中synchronized是基于原子性的内部锁机制,是可重入的,在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性

  线程中断与synchronized:对于synchronized来说,如果一个线程在等待锁,结果只有两种。要么它获得锁继续执行,要么保存等待,即使调用中断线程的方法,也不会有效。

  等待唤醒机制与synchronized:这里主要指notify/notify/wait方法。使用这三个方法必须在synchronized代码块或synchronized方法中,否则就会抛出IllegalMonitorStateException异常。因为调用这几个方法必须拿到当前对象的monitor对象。monitor存在于引用指针中,而synchronized关键字可以获取monitor。与sleep不同的是wait方法调用完后,线程将被暂停,但wait方法会释放掉当前持有的锁。直到线程调用notify/notifyAll方法后才继续执行。sleep方法只让线程休眠并不释放锁。同时notify/notifyAll方法调用后,不会马上释放锁,而是在相应的synchronized代码块或synchronized方法执行结束后才自动释放锁。


synchronized实现原理:

  synchronized同步块:  

    synchronized 同步语句块的实现使用的是 monitorenter 和 monitorexit 指令,其中 monitorenter 指令指向同步代码块的开始位置,monitorexit 指令则指明同步代码块的结束位置。 当执行 monitorenter 指令时,线程试图获取锁也就是获取 monitor(monitor对象存在于每个Java对象的对象头中,synchronized 锁便是通过这种方式获取锁的,也是为什么Java中任意对象可以作为锁的原因) 的持有权.当计数器为0则可以成功获取,获取后将锁计数器设为1也就是加1。相应的在执行 monitorexit 指令后,将锁计数器设为0,表明锁被释放。如果获取对象锁失败,那当前线程就要阻塞等待,直到锁被另外一个线程释放为止。

  synchronized同步方法:

    synchronized 修饰的方法并没有 monitorenter 指令和 monitorexit 指令,取得代之的确实是 ACC_SYNCHRONIZED 标识,该标识指明了该方法是一个同步方法,JVM 通过该 ACC_SYNCHRONIZED 访问标志来辨别一个方法是否声明为同步方法,从而执行相应的同步调用。


4、Lock详解

  在上面synchronized的详解中,我们可以了解到当一个代码块被synchronized修饰,一个线程获取了对应的锁并执行该代码块。其他线程只能一直等待,等待获取锁的线程释放锁。这里获取锁的线程是释放锁的可能有两种:

  1)获取锁的线程执行完该代码块,线程释放锁

  2)线程执行发生异常,jvm会让线程自动释放锁

  如果这个获取锁的线程由于等待IO或其他原因被阻塞且没有释放锁,其他线程便只能等待。这种情况下synchronized就有了一些缺陷。通过Lock我们可以弥补这些缺陷。

  Lock的使用:

  在lock接口中,有四个方法来获取锁。lock()、tryLock()、tryLock(long time,TimeUnit unit) 和lockInterruptibly()。使用unLock()来释放锁。由于lock不会主动释放锁,发生异常时,不会自动释放锁。一般使用Lock必须在try{}catch{}块中进行。并将释放锁的操作放在finally中,保证锁一定被释放,防止死锁的发生。

  Lock():获取锁,如果锁已被其他线程获取,则进行等待

Lock lock = ...;
lock.lock();
try{
//处理任务
}catch(Exception ex){ }finally{
//释放锁
lock.unlock();
}

  tryLock():尝试获取锁,如果获取成功,则返回true,如果获取失败则返回false。该方法无论如何都会立即返回,拿不到锁时不会一直等待。

  tryLock(long time,TimeUnit unit):与tryLock()方法类似,不同的是该方法拿不到锁时会等待一定时间,在时间期限内还拿不到锁就返回false。

Lock lock = ...;
if(lock.tryLock()){
try{
//处理任务
}catch(Exception ex){
}finally{
//释放锁
lock.unlock();
}
}else{
//如果不能获取锁,执行其他任务
}

  lockInterruptibly():获取锁时,如果线程正在等待获取锁,那该线程能响应中断,即中断等待状态。当两个线程同时通过lock.lockInterruptibly()想获取某个锁时,若A线程获取了锁,而B线程只能等待。那么对B线程调用threadB.interrupt()方法能够中断B线程的等待过程。该方法的声明中抛出了异常,使用时必须放在try块中或在调用方法外声明抛出InterruptedException。

public void method() throws InterruptedException {
lock.lockInterruptibly();
try {
//.....
}
finally {
lock.unlock();
}
}

  注意:持有锁的线程,是不会被Interrupt()方法中断的,它只能中断阻塞过程中的线程。当通过lockInterruptibly()方法获取某个锁时,如果不能获取到,只有进行等待的情况下,才可以响应中断。


5、Lock接口的实现类ReentranLock

  ReentrantLock是唯一实现了Lock接口的类,并提供了更多的方法。

  Lock的正确使用

public class Test {
private ArrayList<Integer> arrayList = new ArrayList<Integer>();
//此处声明lock为全局变量
private Lock lock = new ReentrantLock();
public static void main(String[] args) {
final Test test = new Test();
new Thread() {
public void run() {
test.insert(Thread.currentThread());
};
}.start();
new Thread() {
public void run() {
test.insert(Thread.currentThread());
};
}.start();
} public void insert(Thread thread) {
lock.lock();
try {
System.out.println("当前是线程:"+thread.getName()+"获得了锁");
for (int i = 0; i < 5; i++) {
arrayList.add(i);
}
} catch (Exception e) { } finally {
System.out.println("线程:"+thread.getName()+"释放了锁");
lock.unlock();
}
}
}

此处注意,特意在声明Lock的时候注释了是全局变量。因为当lock在方法里创建成局部变量的时候。每个线程执行到lock.lock()获取到的是不同的锁。不会发生冲突。一般使用时将Lock声明为全局变量即可。

在这段代码里的insert()方法使用tryLock()方法,可以知道线程有没有获取到锁并输出结果。

public void insert(Thread thread) {
if(lock.tryLock()) {
try {
System.out.println("当前是线程:"+thread.getName()+"获得了锁");
for (int i = 0; i < 5; i++) {
arrayList.add(i);
}
} catch (Exception e) { } finally {
System.out.println("线程:"+thread.getName()+"释放了锁");
lock.unlock();
}
}else {
System.out.println("线程:"+thread.getName()+"获取锁失败");
}
}

6、ReadWriteLock

  ReadWriteLock定义了两个方法,一个用来获取读锁,一个用来获取写锁。将文件的读写操作分开,分成两个锁来分配给线程。使得多个线程可以同时进行读操作。

  实现类:ReentrantReadWriteLock。主要两个方法readLock()和writeLock()用来获取读锁和写锁。

  一个实例:多个线程同时进行读操作。使用synchronized

public class Test {
private ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public static void main(String[] args) {
final Test test = new Test();
new Thread() {
public void run() {
test.get(Thread.currentThread());
};
}.start();;
new Thread() {
public void run() {
test.get(Thread.currentThread());
};
}.start();;
} public synchronized void get(Thread thread) {
long start = System.currentTimeMillis();
while(System.currentTimeMillis() - start <= 1) {
System.out.println("线程:"+thread.getName()+"正在进行读操作");
}
System.out.println("线程:"+thread.getName()+"读操作完毕");
}
}

这样的输出结果可以发现,一个时间段内只有一个线程在执行读操作。一个线程执行完读操作,另一个线程才有机会执行。

改为读写锁,实现多个线程同时读操作

public  void get(Thread thread) {
rwl.readLock().lock();
try {
long start = System.currentTimeMillis();
while(System.currentTimeMillis() - start <= 1) {
System.out.println("线程:"+thread.getName()+"正在进行读操作");
}
System.out.println("线程:"+thread.getName()+"读操作完毕");
} catch (Exception e) {
// TODO: handle exception
} finally {
rwl.readLock().unlock();
}
}

这段代码的输出结果可以看出,同时两个线程都在执行读操作。这样的话效率大大提升。不过要注意,如果一个线程占用了读锁,此时其他线程要申请写锁,那申请写锁的线程会一直等待释放读锁。如果一个线程占用了写锁,此时其他线程要申请写锁或读锁,则申请的线程会一直等待释放写锁。从性能上看,竞争资源不激烈,lock跟synchronized性能差不多,当竞争资源激烈时,lock的性能要远远优于synchronized。


Synchronized和lock区别:
  1、Synchronized是java语言内置的特性,而lock是一个接口
  2、Synchronized不需要用户手动释放锁,当synchronized方法或代码块执行完后,自动释放锁,而lock需要用户手动释放锁,如果没有手动释放,可能产生死锁
  3、Synchronized修饰时,等待的线程会一直等待不能响应中断,lock可以让等待锁的线程响应中断。
  4、Lock可以知道有没有成功获取锁(tryLock方法),而synchronized不可以
  5、Lock可以提高多个线程进行读操作的效率

7、锁的概念:

  可重入锁:从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。该分配机制是基于线程而非基于方法的调用。

class MyClass{
synchronized void method1(){
method2();
}
synchronized void method2(){
}

  synchronized是可重入锁。当一个线程执行method1时,已经获取到了对象的锁。调用method2就无需重新申请锁。不具备重入性时,线程持有该对象的锁,又去申请该对象的锁。将会使线程一直等待永远获取不到锁。synchronized和Lock都具备可重入性。

  可中断锁:可以响应中断的锁。即可以使在等待中的线程自己中断或者在别的线程中中断它。Lock是可响应中断的,synchronized不是。

  公平锁:尽量以请求锁的顺序来获取锁。多个线程同时等待一个锁,当此锁被释放时,等待最久的线程优先获得该锁。

  非公平锁:无法保证锁的获取是按照请求锁的顺序进行的。这样可能导致某个或一些线程永远获取不到锁,synchronized就是非公平锁,它无法保证等待的线程获取锁的顺序。对于ReentranLock和ReentrantReadWriteLock,它默认是公平锁,也可设置为非公平锁

  读写锁:该锁将一个资源的访问分成了两个锁,读锁和写锁。保证了多个线程之间的读操作不发生冲突。ReadWriteLock是读写锁,它是一个接口。ReentrantReadWriteLock实现了这个接口。可以通过readLock()获取读锁,通过writeLock()获取写锁。

  

  

  

上一篇:VMWare网络链接三种方式


下一篇:十七、Java中数组常见的几种排序方法!