java高并发学习(九)-----多线程的团队协作:同步控制

Java高并发学习(八)-------多线程的团队协作:同步控制

 

  同步控制是并发程序必不可少的重要手段。之前介绍的synchronized关键字就是一种最简单的控制方法。同时,wait()和notify()方法起到了线程等待和通知的作用。这些工具对于实现复杂的多线程协作起到了重要的作用。接下来将介绍synchronized,wait,notify方法的代替品(或者说是增强版)——重入锁。

1. synchronized的功能扩展: 重入锁

    重入锁完全可以代替synchronized关键字。在早期JDK版本,重入锁的性能远远优于synchronized关键字,在JDK后期版本,对synchronized关键字做了大量的优化,使得两者的性能差不多。

    下面展示一段简单的synchronized的使用案例:


public class fist{
    public static ReentrantLock Lock = new ReentrantLock();
    public static int Count = 0;
    
    public static class MyThread extends Thread{
        @Override
        public void run(){
            for(int i=0;i<10000;i++){
                Lock.lock();
                Count++;
                Lock.unlock();
            }
        }
    }
    
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        t1.start();t2.start();
        t1.join();t2.join();
        System.out.println(Count);
    }
}


 上述代码创建了一个全局的ReentrantLock对象,这个对象就是重入锁对象,该对象的lock()和unlock()方法之间的代码区域就是重入锁的保护零界区,确保了多线程对Count变量的操作安全性。

    从这段代码可以看到,与synchronized相比,重入锁有着显示操作的过程。开发人员必须手动指定何时加锁 ,何时释放锁。也正是因为这样,重入锁逻辑控制远远要好于synchronized。但值得注意的是,在退出零界区时,必须记得要释放锁,否者永远没有机会再访问零界区了,会造成其线程的饥饿甚至是死锁。

    重入锁之所以被称作重入锁是因为重入锁是可以反复进入的。当然,这里的反复进入仅仅局限于一个线程。上诉代码还可以这样写:

for(int i=0;i<10000;i++){
    Lock.lock();
    Lock.lock();
    Count++;
    Lock.unlock();
    Lock.unlock();
}


    在这种情况下,一个线程连续两次获得同一把锁。这是允许的!但要注意的是,如果一个线程多次获得锁,那么在释放锁的时候,也必须释放相同次数。

·中断响应

    重入锁除了提供上述的基本功能外,还提供了一些高级功能。比如,重入锁可以提供中断处理的能力。这是一个非常重要的功能,synchronized是没有中断功能的。在等待锁的过程中,程序可以根据需要取消对锁的请求。这是synchronized办不到的。也就是说,重入锁具有解除死锁的功能。

    下面的代码产生了一个死锁,得益于锁的中断,我们可以轻易的解决这个死锁:

public class fist{
    public static ReentrantLock Lock1 = new ReentrantLock();
    public static ReentrantLock Lock2 = new ReentrantLock();
    
    public static class MyThread extends Thread{
        int flag;
        MyThread(int flag){
            this.flag = flag;
        }
        @Override
        public void run(){
            try{
                if(flag == 1){
                    try {
                        Lock1.lockInterruptibly();
                        Thread.sleep(1000);
                        Lock2.lockInterruptibly();
                        System.out.println(flag+"号线程:完成工作");
                    } catch (InterruptedException e) {}
                }
                else if(flag == 2){
                    try {
                        Lock2.lockInterruptibly();
                        Thread.sleep(1000);
                        Lock1.lockInterruptibly();
                        System.out.println(flag+"号线程:完成工作");
                    } catch (InterruptedException e) {}
                }
            }finally{
                //中断响应
                if(Lock1.isHeldByCurrentThread()){
                    Lock1.unlock();
                    System.out.println(flag+":Lock1 interrupted unlock");
                }
                if(Lock2.isHeldByCurrentThread()){
                    Lock2.unlock();
                    System.out.println(flag+":Lock2 interrupted unlock");
                }
                System.out.println(flag+"号线程退出");
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread(1);
        MyThread t2 = new MyThread(2);
        t1.start();
        t2.start();
        Thread.sleep(3000);
        t2.interrupt();
    }
}


    线程t1和线程t2启动后,t1先占用lock1,再占用lock2;t2先占用lock2,再请求lock1。这样很容易形成t1和t2之间的互相等待,造成死锁。在这里,对锁的请求,统一使用lockInterruptibly()方法。这是一个可以对中断进行响应的锁申请动作,即在等待锁的过程中可以响应中断。

    在t1和t2线程start后,主线程main进入休眠,此时t1和t2线程处于死锁状态,然后主线程main中断t2线程,故t2会放弃对lock1的请求,同时释放lock2。这个操作使得t1可以获得lock2从而继续执行下去。

    执行上诉代码,将输出:

    可以看到,中断后,两个线程双双退出。但真正完成工作的只有t1。而t2放弃任务直接退出,释放资源。

·锁申请等待限时

    除了等待外部通知之外,还有一种避免死锁的方法,就是限时等待。通常,我们不会预料到系统在什么时候会产生死锁,就无法主动的解除死锁,最好的系统设计方式是,这个系统根本就不会产生死锁。我们可以用tryLock()方法进行限时等待。

    下面这段代码展示了限时等待锁的使用:

public class fist{
    public static ReentrantLock Lock = new ReentrantLock();
    public static class MyThread extends Thread{
        @Override
        public void run(){
            try {
                if(Lock.tryLock(5,TimeUnit.SECONDS)){
                    Thread.sleep(6000);
                }
                else{
                    System.out.println("get lock failed");
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if(Lock.isHeldByCurrentThread()){
                Lock.unlock();
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        t1.start();
        t2.start();
    }
}


输出结果:

    在这里,trylock()接收两个参数,一个表示等待时长,另一个表示计时单位。这里设置为秒,时长为5,表示线程在这个锁的请求中,最多等待5秒。如果超过5秒还没有得到锁就返回false。如果成功就返回true。
    在本例中,由于占用锁的线程会持有锁长达6秒,故另外一个线程无法在5秒内获得锁,因此,对锁的请求会失败。

    tryLock()方法也可以不带参数直接运行,在这种情况下,当前进程会尝试获得锁,如果锁并未被其他进程占用,则申请就会成功,立即返回true。如果锁被其他线程占用,会立即返回false。这种模式不会引起线程的等待,因此不会造成死锁。下面演示了这种使用方式:

public class fist{
    public static ReentrantLock Lock1 = new ReentrantLock();
    public static ReentrantLock Lock2 = new ReentrantLock();
    public static class MyThread extends Thread{
        int flag;
        MyThread(int flag){
            this.flag = flag;
        }
        @Override
        public void run(){
            if(flag == 1){
                while(true){
                    if(Lock1.tryLock()){
                        try {
                            Thread.sleep(500);
                            if(Lock2.tryLock()){
                                System.out.println(flag+"号线程完成工作");
                            }
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    if(Lock2.isHeldByCurrentThread()){
                        Lock2.unlock();
                    }
                    if(Lock1.isHeldByCurrentThread()){
                        Lock1.unlock();
                    }
                }
            }
            else if(flag == 2){
                while(true){
                    if(Lock2.tryLock()){
                        try {
                            Thread.sleep(300);
                            if(Lock1.tryLock()){
                                System.out.println(flag+"号线程完成工作");
                            }
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                    if(Lock1.isHeldByCurrentThread()){
                        Lock1.unlock();
                    }
                    if(Lock2.isHeldByCurrentThread()){
                        Lock2.unlock();
                    }
                }
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread(1);
        MyThread t2 = new MyThread(2);
        t1.start();
        t2.start();
    }
}

 

    上述代码中,采用了非常容易死锁的加锁顺序。也就是先让线程t1请求lock1,在请求lock2,而让t2先请求lock2,在请求lock1。在一般情况下,这样会导致t1,t2互相等待,从而引起死锁。
    但是采用trylock后,这种情况得到了改善。由于线程不会傻傻的等待,而是不停的尝试,因此,只要执行足够长的时间,线程总是会获得所需要的资源,从而正常执行(这里以线程能同时获得lock1和lock2两把锁视为正常执行)。

代码执行结果如下:

  可以看到,1号线程和2号线程都是有机会被执行到的。但是不能保证,谁先被执行和被执行的次数是平均的。不知道各位有没有注意上述的一句话:“只要执行足够长的时间,线程总是会获得所需要的资源”,这句话给我们提供了两个信息。第一,两个线程都会有被执行到的几乎,第二,不能保证这两个线程被公平的执行。实际上这两个线程也是在互相争夺同一个资源,这两个线程到底谁会被执行,这依靠的是操作系统对线程的调度策略。

  在上述程序执行一段时间后:

    我们可以看到1号线程被执行次数比较多。这表示操作系统的调度不能满足资源平均分配这一需求。这里在多说一句,根据目前大多数操作系统的调度,一个线程倾向于在次获得持有的锁,这种分配方法是高效的,但毫无公平性可言。

·公平锁

    在大多数情况下,如上述情况,锁的申请都是非公平的。也就是说,线程1首先请求了锁A,接着线程2也请求了锁A。那么锁A可用时,线程1可以获得锁还是线程2可以获得锁呢?这是不一定的,系统只会从这个锁的等待队列中随机挑取一个。因此不能保证公平性。
    而接下来要讲的公平锁,他会按照时间的先后顺序,保证先到者先得,后到者后得。所以,公平锁的最大特点就是,他不会产生饥饿现象。
    注意:如果线程采用synchronized进行互斥,那么产生的锁是非公平的。而重入锁允许我们进行公平性设置。他有一个如下的构造函数:
    public ReentranLock(boolean fair);

    

    当参数fair为true时,表示锁是公平的。公平锁看起来很优美,但是要实现公平锁,必然要求系统维护一个有序队列,因此对公平锁得到实现成本比较高,意味着公平锁的效率非常低下,因此,在默认情况下,锁是非公平的。如果没有什么特别的需求,尽量别用公平锁。

    下面代码能很好的凸显公平锁的特点:

public class fist{
    public static ReentrantLock Lock = new ReentrantLock(true);
    public static class MyThread extends Thread{
        @Override
        public void run(){
            while(true){
                Lock.lock();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(getId() + ": 获得锁");
                Lock.unlock();
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread();
        MyThread t2 = new MyThread();
        t1.start();
        t2.start();
    }
}


    代码执行结果:

    可以看到,线程的调度是公平的。

2.  从入锁的好搭档: Condition条件

    如果大家了解object.wait()方法和object.notify()方法的HIA,那么就能很容易理解condition对象了。他和wait()和notify()方法的作用是基本相同的。但是wait()和notify()方法是与synchronized关键字组合使用的,而condition是与重入锁相关联的。
    Condition接口提供的基本方法如下:
    Void await() throws InterrupteException;
    Void awaitUninterruptibly();
    Long awaitNanos(long nanosTimeout) throws InterrupteException;
    Boolean await(long time, TimeUnit unit) throws InterrupteException;
    Boolean awaitUntil(Data deadline) throws InterrupteException;
    Void signal();
    Void signalAll();
    以上方法含义如下:
    ·await()方法会使当前线程等待,同时释放当前锁,当其他线程使用signal()或signalAll()方法时,线程会重新获得锁并继续执行。或者当线程被中断时,也能跳出等待。这和object.wait()方法很相似。
    ·awaitUninterruptibly()方法与wait()方法相同,唯一的不同点是,该方法不会再等待的过程中响应中断。
    ·signal()方法用于唤醒一个在等待中的线程。signalAll()会唤醒所有正在等待的线程。这和object.notify()方法很相似。
    下面代码简单的演示了Condition的作用:


public class fist{
    public static ReentrantLock Lock = new ReentrantLock(true);
    //生成Lock对应的condition对象
    public static Condition condition = Lock.newCondition();
    public static class MyThread extends Thread{
        @Override
        public void run(){
            try {
                Lock.lock();
                condition.await();
                System.out.println("Thread is going on");
                Lock.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        MyThread t1 = new MyThread();
        t1.start();
        //两秒后通知线程继续执行
        Thread.sleep(2000);
        Lock.lock();
        condition.signal();
        Lock.unlock();
    }
}


  上述代码先通过lock生成一个与之绑定的condition对象。后要求线程在condition对象上进行等待。主线程main在两秒后发出signal通知,告知等待在condition上的线程可以继续执行了。

  和object.wait()和object.notify()一样,当线程使用Condition.wait()时,要求线程持有相关的从入锁,在condition.wait()调用后,这个线程会主动释放这把锁。并且,在condition.signal()方法调用时,也要求线程获取相关的锁。注意,在signal()方法调用之后,一定要释放相关的锁,把锁让给其他线程。

  当主线程调用signal()方法之后,会从等待队列中随机唤醒一个wait中的线程,这个线程会重新进行锁的争夺。这里很容易让读者产生一个疑问,在线程唤醒后是重新执行零界区代码,还是继续执行condition.wait()方法后的代码?这里给出答复,是继续执行condition.wait()方法后的代码(JDK会像中断一样保存线程断点)。

3.  允许多个线程同时访问:信号量(Semaphore)

  信号量为多线程协作提供了更为强大的控制方法。广义上说,信号量是对锁的扩展。无论是内部锁synchronized还是重入锁ReentranLock,一次都只允许一个线程访问一个资源,而信号量却可以指定多个线程,同时访问一个资源。信号量主要提供了一下的构造函数:

  Public Semaphore(int permits);

  Public Semaphore(int permits, boolean fair); //第二个参数可以指定是否公平

  在构造信号量时,必须指定信号量的准入数,即同时能申请几个许可。当每个线程只申请一个许可时,这就相当于指定了同时能有多少个线程可以访问某个资源。信号量的主要逻辑方法有:

Public void acquire();

Public void acquireUninterruptibly();

Public boolean tryAcquire();

Public boolean tryAcquire(long timeout, TimeUnit unit);

Public void release();

  acquire()方法尝试获得一个准入的许可。若无法获得,则线程会等待,直到申请到许可或者当前线程被中断。acquireUninterruptibly()方法与acquire()方法类似,但不响应中断。tryAcquire()尝试获得一个许可,成功返回true失败返回false,它不会进行阻塞等待,立即返回。release()用于在线程访问资源结束后,释放一个许可,以使其他等待许可的线程可以进行资源访问。

 下面是Semaphore的简单使用:

public class fist{
    public static Semaphore semp = new Semaphore(5);
    public static class MyThread extends Thread{
        @Override
        public void run(){
            try {
                semp.acquire();
                //模拟耗时操作
                Thread.sleep(2000);
                System.out.println(Thread.currentThread().getId()+": done!");
                semp.release();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    public static void main(String args[]) throws InterruptedException{
        ExecutorService exec = Executors.newFixedThreadPool(20);
        MyThread t1 = new MyThread();
        for(int i=0;i<20;i++){
            exec.submit(t1);
        }
    }
}


  在本例中同时开启了20个线程。观察上述程序的输出,你会发现线程以5个线程为一组依次输出。


4. ReadWriteLock 读写锁

ReadWriteLock 是JDK5中提供的读写分离锁。读写锁能有效的帮助减少锁竞争,以提升系统性能。

·读-读不互斥:读读之间不阻塞。

·读-写互斥:读阻塞写,写也会阻塞读。

·写-写互斥:写写阻塞。

如果系统中,读操作次数远远大于写操作,则读写锁可以发挥最大的功效,提升系统性能。这里给出一个稍微夸张的案例,来说明读写锁对性能的帮助。

public class fist{
    //普通锁
    private static Lock lock = new ReentrantLock();
    //读写锁
    private static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private static Lock readLock = readWriteLock.readLock();
    private static Lock writeLock = readWriteLock.writeLock();
    private static int value;
    private static int runtime = 0;
    
    //模拟读操作
    public static int handleRead(Lock lock) throws InterruptedException{
        try {
            lock.lock();
            Thread.sleep(1000);
            return value;
        }finally{
            lock.unlock();
        }
    }
    //模拟写操作
    public static void handleWrite(Lock lock, int index) throws InterruptedException{
        try {
            lock.lock();
            Thread.sleep(1000);
            value = index;
        }finally{
            lock.unlock();
        }
    }
    public static class Mythread_Read extends Thread{
        @Override
        public void run(){
            try {
                //handleRead(readLock);    //使用读写锁
                handleRead(lock);      //使用普通锁
            } catch (InterruptedException e) {}
        }
    }
    public static class Mythread_Write extends Thread{
        @Override
        public void run(){
            try {
                //handleWrite(writeLock, 0);    //使用读写锁
                handleWrite(lock, 0);       //使用普通锁
            } catch (InterruptedException e) {}
        }
    }
    //守护线程,用来记录运行时间
    public static class Deamon extends Thread{
        @Override
        public void run(){
            try {
                while(true){
                    System.out.println("use time: " + runtime);
                    runtime++;
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {}
        }
    }
    
    public static void main(String args[]) throws InterruptedException{
        Deamon deamon = new Deamon();
        deamon.setDaemon(true);
        deamon.start();
        for(int i=0;i<18;i++){
            Mythread_Read read = new Mythread_Read();
            read.start();
        }
        for(int i=0;i<2;i++){
            Mythread_Write write = new Mythread_Write();
            write.start();
        }
    }
}


    上述代码中,比较了使用读写锁和普通锁时,系统完成读写任务所需要的时间,这里设置读任务要比写任务多得多。设置一个守护线程来记录整个读写操作完成所需要的时间。

 执行结果(不用读写锁):


可以看到,不用读写锁,程序花费了20秒的时间才完成读写任务。

执行结果(采用读写锁):


可以看到,采用读写锁,程序需要3秒就完成读写任务了。

所以,当一个系统中读者数量明显多于写者时,使用读写锁能大大减小系统的开销,这一点非常重要。


--------------------- 
 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值