互联网科技Java成长之路Java架构技术进阶

阿里Java研发面试题『八部曲』——详解多线程、锁

2019-10-11  本文已影响0人  程序员北游

欢迎关注专栏:Java架构技术进阶。里面有大量batj面试题集锦,还有各种技术分享,如有好文章也欢迎投稿哦。

1、实现多线程的两种方法

实现多线程有两种方法:继承Thread和实现Runnable接口。

继承Thread:
以卖票为例:

public class MyThread extends Thread {
    private static int COUNT = 5;
    private int ticket = COUNT;
    private String name;
    public MyThread(String s){
        name = s;
    }
    @Override
        public void run() {
        for (int i = 0; i < COUNT; i++){
            if(ticket > 0){
                System.out.println(name + "-->" + ticket--);
            }
        }
    }

测试使用:

MyThread thread1 = new MyThread("thread1");
        MyThread thread2 = new MyThread("thread2");
        thread1.start();
        thread2.start();

输出:

thread1-->5
thread2-->5
thread1-->4
thread2-->4
thread1-->3
thread2-->3
thread1-->2
thread2-->2
thread1-->1
thread2-->1

可以看到,这种方式每个线程自己拥有了一份票的数量,没有实现票的数量共享。下面看实现Runnable的方式:

实现Runnable接口:

public class MyRunnable implements Runnable {
    private static int COUNT = 5;
    private int ticket = COUNT;

    @Override
    public void run() {
        for(int i = 0; i < COUNT; i++){
            if(ticket > 0){
                System.out.println("ticket-->" + ticket--);
            }
        }
    }
}

测试使用:

 MyRunnable runnable = new MyRunnable();
        new Thread(runnable).start();
        new Thread(runnable).start();

输出:

ticket-->5
ticket-->3
ticket-->2
ticket-->1
ticket-->4

可以看到,实现Runnable的方式可以实现同一资源的共享。
实际工作中,一般使用实现Runnable接口的方式,是因为:

拓展
Thread的start()和run()方法区别:

start()方法用于启动一个线程,使其处于就绪状态,得到了CPU就会执行,而直接调用run()方法,就相当于是普通的方法调用,会在主线程中直接运行,此时没有开启一个线程。

下列方法中哪个是执行线程的方法? ()

正确答案:A

2、访问控制修饰符(新补充)

关于访问控制修饰符,在第一篇总结中已有详细的介绍。但最近在使用String类的一个方法compareTo()的时候,对private修饰符有了新的理解。String类的compareTo方法是用来比较两个字符串的字典序的,其源码如下:

public int compareTo(String anotherString) {
    int len1 = value.length;
    int len2 = anotherString.value.length;
    //重点是这里!!!
    int lim = Math.min(len1, len2);
    char v1[] = value;
    char v2[] = anotherString.value;
    //重点是这里!!!
    int k = 0;
    while (k < lim) {
        char c1 = v1[k];
        char c2 = v2[k];
        if (c1 != c2) {
            return c1 - c2;
        }
        k++;
    }
    return len1 - len2;
}

上面代码逻辑很好理解,我在看到它里面直接使用anotherString.value来获取String的字符数组的时候很奇怪,因为value是被定义为private的,只能在类的内部使用,不能在外部通过类对象.变量名的方式访问。我们平常都是通过String类的toCharArray()方法来获取String的字符数组的,看到上面的这种使用方法,我赶紧在别的地方测试了一下,发现的确是不能直接通过xx.value的方法来获取字符数组。

正如前面所说的,value是被定义为private的,只能在类的内部使用,不能在外部通过类对象.变量名的方式访问。因为compareTo方法就是String类的内部成员方法,compareTo方法的参数传递的就是String对象过来,此时使用“类对象.变量名”的方式是在该类的内部使用,因此可以直接访问到该类的私有成员。自己再模仿String类来测试一下,发现果然如此。。

问题很细节,但是没有一下想通,说明还是对private的修饰符理解不够到位,前面自认为只要是private修饰的,就不能通过“类对象.变量名”的方式访问,其实还是需要看在哪里面使用。

3、线程同步的方法

当我们有多个线程要访问同一个变量或对象时,而这些线程中既有对改变量的读也有写操作时,就会导致变量值出现不可预知的情况。如下一个取钱和存钱的场景:

没有加入同步控制的情形:

public class BankCount {
    private int count = 0;
    //余额
    public void addMoney(int money){
        //存钱
        count += money;
        System.out.println(System.currentTimeMillis() + "存入:" + money);
        System.out.println("账户余额:" + count);
    }
    public void getMoney(int money){
        //取钱
        if(count - money < 0){
            System.out.println("余额不足");
            System.out.println("账户余额:" + count);
            return;
        }
        count -= money;
        System.out.println(System.currentTimeMillis() + "取出:" + money);
        System.out.println("账户余额:" + count);
    }
}

测试类:

public class BankTest {
    public static void main(String[] args) {
        final BankCount bankCount = new BankCount();
        new Thread(new Runnable() {
            //取钱线程
            @Override
                        public void run() {
                while(true){
                    bankCount.getMoney(200);
                    try {
                        Thread.sleep(1000);
                    }
                    catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        ).start();
        new Thread(new Runnable() {
            //存钱线程
            @Override
                        public void run() {
                while(true){
                    bankCount.addMoney(200);
                    try {
                        Thread.sleep(1000);
                    }
                    catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }
        ).start();
    }
}

部分打印结果如下:

余额不足
账户余额:0
1462265808958存入:200
账户余额:200
1462265809959存入:200
账户余额:200
1462265809959取出:200
账户余额:200
1462265810959取出:200
账户余额:200
1462265810959存入:200
账户余额:200
1462265811959存入:200
账户余额:200

可以看到,此时有两个线程共同使用操作了bankCount对象中的count变量,使得count变量结果不符合预期。因此需要进行同步控制,同步控制的方法有以下几种:

(1)使用synchronized关键字同步方法

每一个Java对象都有一个内置锁,使用synchronized关键字修饰的方法,会使用Java的内置锁作为锁对象,来保护该方法。每个线程在调用该方法前,都需要获得内置锁,如果该锁已被别的线程持有,当前线程就进入阻塞状态。

修改BankCount 类中的两个方法,如下:

public synchronized void addMoney(int money){
    //存钱
    count += money;
    System.out.println(System.currentTimeMillis() + "存入:" + money);
    System.out.println("账户余额:" + count);
}
public synchronized void getMoney(int money){
    //取钱
    if(count - money < 0){
        System.out.println("余额不足");
        System.out.println("账户余额:" + count);
        return;
    }
    count -= money;
    System.out.println(System.currentTimeMillis() + "取出:" + money);
    System.out.println("账户余额:" + count);
}

运行测试打印如下结果:

余额不足
账户余额:0
1462266451171存入:200
账户余额:200
1462266452171取出:200
账户余额:0
1462266452171存入:200
账户余额:200
1462266453171存入:200
账户余额:400
1462266453171取出:200
账户余额:200
1462266454171存入:200
账户余额:400
1462266454171取出:200
账户余额:200
1462266455171取出:200
账户余额:0

可以看到,打印结果符合我们的预期。

另外,如果我们使用synchronized关键字来修饰static方法,此时调用该方法将会锁住整个类。(关于类锁、对象锁下面有介绍)

(2)使用synchronzied关键字同步代码块

使用synchronized关键字修饰的代码块,会使用对象的内置锁作为锁对象,实现代码块的同步。

改造BankCount 类的两个方法:

public void addMoney(int money){
    //存钱
    synchronized(this){
        count += money;
        System.out.println(System.currentTimeMillis() + "存入:" + money);
        System.out.println("账户余额:" + count);
    }
}
public void getMoney(int money){
    //取钱
    synchronized(this){
        if(count - money < 0){
            System.out.println("余额不足");
            System.out.println("账户余额:" + count);
            return;
        }
        count -= money;
        System.out.println(System.currentTimeMillis() + "取出:" + money);
        System.out.println("账户余额:" + count);
    }
}

(注:这里改造后的两个方法中因为synchronized包含了方法体的整个代码语句,效率上与在方法名前加synchronized的第一种同步方法差不多,因为里面涉及到了打印money还是需要同步的字段,所以全部包含起来,仅仅是为了说明synchronized作用...)

打印结果:

余额不足
账户余额:0
1462277436178存入:200
账户余额:200
1462277437192存入:200
账户余额:400
1462277437192取出:200
账户余额:200
1462277438207取出:200
账户余额:0
1462277438207存入:200
账户余额:200
1462277439222存入:200
账户余额:400
1462277439222取出:200
账户余额:200

可以看到,执行结果也符合我们的预期。

synchronized同步方法和同步代码块的选择:

同步是一种比较消耗性能的操作,应该尽量减少同步的内容,因此尽量使用同步代码块的方式来进行同步操作,同步那些需要同步的语句(这些语句一般都访问了一些共享变量)。但是像我们上面举得这个例子,就不得不同步方法的整个代码块,因为方法中的代码每条语句都涉及了共享变量,因此此时就可以直接使用synchronized同步方法的方式。

(3)使用重入锁(ReentrantLock)实现线程同步

重入性:是指同一个线程多次试图获取它占有的锁,请求会成功,当释放锁的时候,直到重入次数为0,锁才释放完毕。

ReentrantLock是接口Lock的一个具体实现类,和synchronized关键字具有相同的功能,并具有更高级的一些功能。如下使用:

public class BankCount {
    private Lock lock = new ReentrantLock();
    //获取可重入锁
    private int count = 0;
    //余额
    public void addMoney(int money){
        //存钱
        lock.lock();
        try {
            count += money;
            System.out.println(System.currentTimeMillis() + "存入:" + money);
            System.out.println("账户余额:" + count);
        }
        finally{
            lock.unlock();
        }
    }
    public void getMoney(int money){
        //取钱
        lock.lock();
        try {
            if(count - money < 0){
                System.out.println("余额不足");
                System.out.println("账户余额:" + count);
                return;
            }
            count -= money;
            System.out.println(System.currentTimeMillis() + "取出:" + money);
            System.out.println("账户余额:" + count);
        }
        finally{
            lock.unlock();
        }
    }
}

部分打印结果:

1462282419217存入:200
账户余额:200
1462282420217取出:200
账户余额:0
1462282420217存入:200
账户余额:200
1462282421217存入:200
账户余额:400
1462282421217取出:200
账户余额:200
1462282422217存入:200
账户余额:400
1462282422217取出:200
账户余额:200
1462282423217取出:200
账户余额:0

同样结果符合预期,说明使用ReentrantLock也是可以实现同步效果的。使用ReentrantLock时,lock()和unlock()需要成对出现,否则会出现死锁,一般unlock都是放在finally中执行。

synchronized和ReentrantLock的区别和使用选择:

1、使用synchronized获得的锁存在一定缺陷

不能中断一个正在试图获得锁的线程
试图获得锁时不能像ReentrantLock中的trylock那样设定超时时间 ,当一个线程获得了对象锁后,其他线程访问这个同步方法时,必须等待或阻塞,如果那个线程发生了死循环,对象锁就永远不会释放;
每个锁只有单一的条件,不像condition那样可以设置多个

2、尽管synchronized存在上述的一些缺陷,在选择上还是以synchronized优先

如果synchronized关键字适合程序,尽量使用它,可以减少代码出错的几率和代码数量 ;(减少出错几率是因为在执行完synchronized包含完的最后一句语句后,锁会自动释放,不需要像ReentrantLock一样手动写unlock方法;)
如果特别需要Lock/Condition结构提供的独有特性时,才使用他们 ;(比如设定一个线程长时间不能获取锁时设定超时时间或自我中断等功能。)
许多情况下可以使用java.util.concurrent包中的一种机制,它会为你处理所有的加锁情况;(比如当我们在多线程环境下使用HashMap时,可以使用ConcurrentHashMap来处理多线程并发)。

下面两种同步方式都是直接针对共享变量来设置的:

(4)对共享变量使用volatile实现线程同步

修改BankCount类如下:

public class BankCount {
    private volatile int count = 0;
    //余额
    public void addMoney(int money){
        //存钱
        count += money;
        System.out.println(System.currentTimeMillis() + "存入:" + money);
        System.out.println("账户余额:" + count);
    }
    public void getMoney(int money){
        //取钱
        if(count - money < 0){
            System.out.println("余额不足");
            System.out.println("账户余额:" + count);
            return;
        }
        count -= money;
        System.out.println(System.currentTimeMillis() + "取出:" + money);
        System.out.println("账户余额:" + count);
    }
}

部分打印结果:

余额不足
账户余额:200
1462286786371存入:200
账户余额:200
1462286787371存入:200
账户余额:200
1462286787371取出:200
账户余额:200
1462286788371取出:200
1462286788371存入:200
账户余额:200
账户余额:200
1462286789371存入:200
账户余额:200

可以看到,使用volitale修饰变量,并不能保证线程的同步。volitale相当于一种“轻量级的synchronized”,但是它不能代替synchronized,volitale的使用有较强的限制,它要求该变量状态真正独立于程序内其他内容时才能使用 volatile。volitle的原理是每次线程要访问volatile修饰的变量时都是从内存中读取,而不是从缓存当中读取,以此来保证同步(这种原理方式正如上面例子看到的一样,多线程的条件下很多情况下还是会存在很大问题的)。因此,我们尽量不会去使用volitale。

(5)ThreadLocal实现同步局部变量

使用ThreadLocal管理变量,则每一个使用该变量的线程都获得该变量的副本,副本之间相互独立,这样每一个线程都可以随意修改自己的变量副本,而不会对其他线程产生影响。

ThreadLocal的主要方法有

如下使用:

public class BankCount {
    private static ThreadLocal<Integer> count = new ThreadLocal<Integer>(){
        protected Integer initialValue() {
            return 0;
        }
        ;
    }
    ;
    //余额
    public void addMoney(int money){
        //存钱
        count.set(count.get() + money);
        System.out.println(System.currentTimeMillis() + "存入:" + money);
        System.out.println("账户余额:" + count.get());
    }
    public void getMoney(int money){
        //取钱
        if(count.get() - money < 0){
            System.out.println("余额不足");
            System.out.println("账户余额:" + count.get());
            return;
        }
        count.set(count.get() - money);
        System.out.println(System.currentTimeMillis() + "取出:" + money);
        System.out.println("账户余额:" + count.get());
    }
}

部分打印结果:

余额不足
1462289139008存入:200
账户余额:0
账户余额:200
余额不足
账户余额:0
1462289140008存入:200
账户余额:400
余额不足
账户余额:0
1462289141008存入:200
账户余额:600
余额不足
账户余额:0

从打印结果可以看到,测试类中的两个线程分别拥有了一份count拷贝,即取钱线程和存钱线程都有一个count初始值为0的变量,因此可以一直存钱但是不能取钱。

ThreadLocal使用时机:

由于ThreadLocal管理的局部变量对于每个线程都会产生一份单独的拷贝,因此ThreadLocal适合用来管理与线程相关的关联状态,典型的管理局部变量是private static类型的,比如用户ID、事物ID,我们的服务器应用框架对于每一个请求都是用一个单独的线程中处理,所以事物ID对每一个线程是唯一的,此时用ThreadLocal来管理这个事物ID,就可以从每个线程中获取事物ID了。

ThreadLocal和前面几种同步机制的比较

4、锁的等级:方法锁、对象锁、类锁

Java中每个对象实例都可以作为一个实现同步的锁,也即对象锁(或内置锁),当使用synchronized修饰普通方法时,也叫方法锁(对于方法锁这个概念我觉得只是一种叫法,因为此时用来锁住方法的可能是对象锁也可能是类锁),当我们用synchronized修饰static方法时,此时的锁是类锁。

对象锁的实现方法

上面两种方式获得的锁是同一个锁对象,即当前的实例对象锁。(当然,也可以使用其他传过来的实例对象作为锁对象),如下实例:

public class BankCount {
    public synchronized void addMoney(int money){
        //存钱
        synchronized(this){
            //同步代码块
            int i = 5;
            while(i-- > 0){
                System.out.println(Thread.currentThread().getName() + ">存入:" + money);
                try {
                    Thread.sleep(500);
                }
                catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    public synchronized void getMoney(int money){
        //取钱
        int i = 5;
        while(i-- > 0){
            System.out.println(Thread.currentThread().getName() + ">取钱:" + money);
            try {
                Thread.sleep(500);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

测试类:

public class BankTest {
    public static void main(String[] args) {
        final BankCount bankCount = new BankCount();
        new Thread(new Runnable() {
            //取钱线程
            @Override
                        public void run() {
                bankCount.getMoney(200);
            }
        }
        ,"取钱线程").start();
        new Thread(new Runnable() {
            //存钱线程
            @Override
                        public void run() {
                bankCount.addMoney(200);
            }
        }
        ,"存钱线程").start();
    }
}

打印结果如下:

取钱线程>取钱:200
取钱线程>取钱:200
取钱线程>取钱:200
取钱线程>取钱:200
取钱线程>取钱:200
存钱线程>存入:200
存钱线程>存入:200
存钱线程>存入:200
存钱线程>存入:200
存钱线程>存入:200

打印结果表明,synchronized修饰的普通方法和代码块获得的是同一把锁,才会使得一个线程执行一个线程等待的执行结果。

类锁的实现方法:

因为static的方法是属于类的,因此synchronized修饰的static方法获取到的肯定是类锁,一个类可以有很多对象,但是这个类只会有一个.class的二进制文件,因此这两种方式获得的也是同一种类锁。

如下修改一下上面代码的两个方法:

public void addMoney(int money){
    //存钱
    synchronized(BankCount.class){
        int i = 5;
        while(i-- > 0){
            System.out.println(Thread.currentThread().getName() + ">存入:" + money);
            try {
                Thread.sleep(500);
            }
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
public static synchronized void getMoney(int money){
    //取钱
    int i = 5;
    while(i-- > 0){
        System.out.println(Thread.currentThread().getName() + ">取钱:" + money);
        try {
            Thread.sleep(500);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

打印结果和上面一样。说明这两种方式获得的锁是同一种类锁。
类锁和对象锁是两种不同的锁对象,如果将addMoney方法改为普通的对象锁方式,继续测试,可以看到打印结果是交替进行的。

后面一篇,将总结线程池ThreadPool、生产者消费者问题及实现、sleep和wait方法区别。

写在最后

更多Java面试题请加VX->"Angel_CoCc",免费领取

部分面试题截图,总有你想要的的或者你需要的!

上一篇 下一篇

猜你喜欢

热点阅读