六、Java高级特性(多线程对象及变量的并发访问)

2021-05-27  本文已影响0人  大虾啊啊啊

一、非线程安全

多个线程对同一个对象中的实例变量进行并发操作时会出现值被更改、值不同步的情况,进而影响程序的执行流程。

二、线程安全

线程安全就是获得实例变量的值是经过同步处理的、不会出现被更改不同步的情况。
两个例子来演示非线程安全和线程安全:

package com.company;
class User {
    String sex;
    public void getUserInfo(int type) {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            if (type == 0) {
                sex = "男";
                Thread.sleep(2000);
            } else {
                sex = "女";
            }
            System.out.println("type = " + type + ":sex = " + sex);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}

两个线程类

package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo(0);
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo(1);
    }
}

mai函数中调用

package com.company;

public class Main {
    public static void main(String[] args) {
        User user = new User();
        new AThread(user).start();
        new BThread(user).start();
    }
}

打印结果:

type = 1:sex = 女
type = 0:sex = 女

上面的例子我们看到,AThread 线程启动的时候,调用getUserInfo方法传的参数是0,这个时候sex的值应该是男,然后休眠了2000毫秒,于此同时BThread 线程也已经启动了,调用getUserInfo方法穿的参数是1,这个时候sex的值应该是女,接着B线程调用先打印type = 1:sex = 女;因为此时A线程还在休眠,等2000毫秒之后A线程继续打印,这个时候sex的值已经被改成女,所以A线程调用打印的结果是type = 0:sex = 女。这就产生了一个很明显的线程安全的问题,明明A线程传入的是0,期望结果是男,却打印成女。

package com.company;
class User {
    public void getUserInfo(int type) {
        String sex;
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            if (type == 0) {
                sex = "男";
                Thread.sleep(2000);
            } else {
                sex = "女";
            }
            System.out.println("type = " + type + ":sex = " + sex);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}
type = 1:sex = 女
type = 0:sex = 男

我们把User类中的sex改成方法内的局部变量,就解决了这一非线程安全的问题。因此我们得出结论。方法内的变量不存在非线程安全问题,因为方法内的局部变量是私有的。
那如果我们在开发中一定要用到成员变量,那要怎么解决这一问题呢?

三、解决线程安全问题的办法

1、synchronized同步方法

package com.company;

class User {
    String sex;
    synchronized public void getUserInfo(int type) {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            if (type == 0) {
                sex = "男";
                Thread.sleep(2000);
            } else {
                sex = "女";
            }
            System.out.println("type = " + type + ":sex = " + sex);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

    }
}
type = 0:sex = 男
type = 1:sex = 女

我们在getUserInfo方法中加入synchronized 关键字,结果打印就正常了。由于A线程先拿到了锁,所以B线程必须先等待A线程打印完,B线程才打印。

使用synchronized关键字取得的锁都是对象锁,而不是把一段代码或者方法当做锁,也就是说,哪个线程先执行了带关键字synchronized 的方法就拿到了该方法所属对象的锁,其他线程只能等待状态,等待线程执行方法完毕之后,释放锁,才能执行该方法拿到对象的锁。这里的前提是同一个对象,如果是多个对象,那JVM就会创建多个锁,每个线程拿到属于自己的锁,就不会存在线程安全的问题。

package com.company;

public class Main {
    public static void main(String[] args) {
        new AThread(new User()).start();
        new BThread(new User()).start();
    }
}

type = 1:sex = 女
type = 0:sex = 男

因为这个时候是两个User对象,A线程和B线程各自拥有自己的锁,不需要等待,因此B线程先打印了,而A线程休眠了2000毫秒之后才打印。

1.1、synchronized方法与对象锁

上面我们提到synchronized关键字用来修饰方法的时候,是锁该方法所属的对象,而不是锁住某个方法。

package com.company;

class User {
    synchronized public void getUserInfo() {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            Thread.sleep(1000);
            System.out.println("1S之后");
            System.out.println("用户信息...");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    synchronized public void getWorkInfo() {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            System.out.println("工作信息...");
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}
package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo();
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getWorkInfo();
    }
}
package com.company;

public class Main {
    public static void main(String[] args) {
        User user = new User();
        new AThread(user).start();
        new BThread(user).start();
    }
}

1S之后
用户信息...
工作信息...

以上的例子,我们在User类中创建了两个方法,都加上了锁,A线程调用了getUserInfo方法,B现象调用了getWorkInfo。这个时候由于A线程先拿到了CPU资源执行了getUserInfo方法拿到了User对象的锁,然后休眠1S,这个时候B线程虽然调用的是getWorkInfo,但是这个时候锁不在自己身上,在A身上,所以只能等待A线程先执行完毕,所以1S之后A线程先打印了用户信息...,释放锁之后,B线程拿到锁打印了工作信息...。根据以上结论我们知道:synchronized关键字锁的是方法所在的对象,不是锁住某个方法。

1.2、脏读

以上我们演示了在一个User类中声明了两个方法都加上了synchronized 关键自己,根据synchronized 是锁对象的机制,一旦有一个线程先执行了带synchronized 的方法,就拿到了该对象的锁,其他线程只能处于等待状态,暂时无法执行带synchronized 关键字的方法,直到之前的线程执行完毕释放了锁。上面我们说的是其他线程等待状态,不能调用带synchronized 的方法,那如果是不带synchronized 的普通方法呢?也就是User类中,一个带synchronized 一个不带。

package com.company;

class User {
    private int i = 0;
    synchronized public void getUserInfo() {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            Thread.sleep(1000);
            i++;
            System.out.println("1S之后");
            System.out.println("getUserInfo = 当前I的值:"+i);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
     public void getWorkInfo() {
        //方法内的变量是私有的,不会存在线程安全问题
        try {
            i++;
            System.out.println("getWorkInfo = 当前I的值:"+i);
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}
getWorkInfo = 当前I的值:1
1S之后
getUserInfo = 当前I的值:2

结果很明显A线程调用的getUserInfo 方法虽然加了锁,拿到了对象的锁,但是B线程依然可以调用getWorkInfo 方法,不需要等待A线程执行完,由于A线程等待了1S,而I的值被B线程又加了一次,结果A线程最终打印的结果是2。这就是出现了脏读的现象,简单的来说就是:A线程和B线程是异步的,不存在排队的情况。原因就是getWorkInfo方法没有加锁。即使A线程拿到了对象的锁,但是针对没有解锁的方法是无效的。

1.3、重入锁

关键字synchronized具有重入锁的功能,也就是当一个线程拿到对象锁的时候,再次请求对象锁的时候再次得到该对象的锁。
User类创建了四个方法并且都加上了锁。

package com.company;

class User {
    synchronized public void test1() {
        System.out.println("test1");
        test2();
    }
    synchronized public void test2() {
        System.out.println("test2");
        test3();
    }
    synchronized public void test3() {
        System.out.println("test3");
    }
    synchronized public void test4() {
        System.out.println("test4");
    }

}

创建线程A和B,A线程调用test1(test1内部调用了test2,test2内部调用了test3都是枷锁的方法),B线程调用了test4(也是枷锁的方法)

package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.test1();
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.test4();
    }
}

main 函数中启AB动线程调用

package com.company;

public class Main {
    public static void main(String[] args) {
        User user = new User();
        new AThread(user).start();
        new BThread(user).start();
    }
}

test1
test2
test3
test4

我们看到打印结果就很明显,A线程调用了test1优先拿到了锁,接着内部会继续调用test2,test2继续调用test3。最后A执行完毕,B才拿到锁,调用test4。也就说明了synchronized具有重入锁的功能

1.4、同步(synchronized)不具有继承性
package com.company;

public class Parent {
    synchronized public void test(){
        System.out.println("你好这是父亲...");
    }
}
package com.company;

class User extends Parent {
    @Override
    public  void test() {
        System.out.println("你好这是孩子开始..."+Thread.currentThread().getName());
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("你好这是孩子结束..."+Thread.currentThread().getName());
    }
}

User继承了Parent ,重写了test方法,但是重写的方法没有加synchronized 修饰。我们看调用打印结果

package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.test();
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.test();
    }
}
package com.company;

public class Main {
    public static void main(String[] args) {
        User user = new User();
        new AThread(user).start();
        new BThread(user).start();
    }
}
你好这是孩子开始...Thread-0
你好这是孩子开始...Thread-1
你好这是孩子结束...Thread-1
你好这是孩子结束...Thread-0

从打印结果来看,A线程和B线程不存在排队执行。因此可知同步不具有继承性,当然我们可以在子类的test方法中加synchronized 关键字就变成了同步的方法。

2、synchronized同步语句块

用synchronized关键字同步方法有些情况下是有弊端的,举个例子:

package com.company;

import java.util.Date;
class User {
    private String name;
    synchronized public void getUserInfo(int type) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (type == 0) {
            name = "小明";
        }
        if (type == 1) {
            name = "小红";
        }
        System.out.println("当前时间:" + new Date().getTime() + "-获取的name为:" + name + "-当前线程:" + Thread.currentThread().getName());
    }
}
当前时间:1607477056617-获取的name为:小明-当前线程:A
当前时间:1607477058617-获取的name为:小红-当前线程:B

我们假设 getUser是从服务器获取用户信息的一个方法,需要耗时2S,那么如果A线程先拿到锁的话,B线程必须等待A线程执行完毕,才能继续执行,这样最终需要时间就是4S。会导致效率比较慢。这就是synchronized同步方法的一个弊端,我们可以通过同步语句块来解决。

package com.company;

import java.util.Date;

class User {
    private String name;

     public void getUserInfo(int type) {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (this) {
            if (type == 0) {
                name = "小明";
            }
            if (type == 1) {
                name = "小红";
            }
            System.out.println("当前时间:" + new Date().getTime() + "-获取的name为:" + name + "-当前线程:" + Thread.currentThread().getName());
        }

    }
}
当前时间:1607477244343-获取的name为:小红-当前线程:B
当前时间:1607477244343-获取的name为:小明-当前线程:A

我们把锁加在拿到数据后给成员变量赋值的地方,因为只有在给成员变量赋值的时候可能会出现线程不安全,最终他们的打印结果是同时等待了2S最后打印。以上的结论就是在synchronized 修饰的代码中是同步执行的,没有修饰的部分就是异步执行的。和之前提到的用synchronized 修饰的方法和没有用synchronized 修饰的方法是一个道理。

2.1、同步语句块也是锁对象

前面提到了synchronized 关键字修饰方法的时候,锁的是该方法所属的对象,而不是锁住该方法。对于同步语句块来说也是一样的,synchronized 修饰语句块的时候,也是锁住当前所属的对象。

package com.company;

class User {
     public void getUserInfo() {
        synchronized (this) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("当前线程getUserInfo:"+Thread.currentThread().getName());
        }
    }
    public void getWorkInfo() {
        synchronized (this) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("当前线程getWorkInfo:"+Thread.currentThread().getName());
        }
    }
}
当前线程getUserInfo:A
当前线程getWorkInfo:B

以上线程A 先拿到了锁,所以B线程只能等待A线程执行完毕,才能执行。说明A拿到的锁是对象锁。同步语句块的方式,相对同步方法来说虽然能提高了一些效率,但是因为同步语句块也是拿到的是该语句块所属对象的锁,其他线程调用其他锁方法的时候,只能等待。

2.2、将任意对象作为监视器

为了解决以上的效率问题,我们可以将某个共享变量作为监视器,加上锁

package com.company;

class User {
    String name = new String();

    public void getUserInfo() {
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        synchronized (name) {
            System.out.println("当前线程getUserInfo:" + Thread.currentThread().getName());
        }
    }

    synchronized public void getWorkInfo() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("当前线程getWorkInfo:" + Thread.currentThread().getName());
    }
}
当前线程getWorkInfo:B
当前线程getUserInfo:A
当前线程getUserInfo:B

根据打印结果我们看到B调用getWorkInfo先打印了,而因为getUserInfo方法中同步的是name属性,因此A和B调用的时候,在同步语句块里是同步的。简单的来说 synchronized (name)锁住某个变量,其实就是锁住该name对象,而不是锁住当前对象。因此A线程调用getUserInfo和B线程调用getWorkInfo是异步的。

2.3、数据类型String常量池的特性

我们先来看一个例子

package com.company;

public class Main {
    public static void main(String[] args) {
        String a = "AA";
        String b = "AA";
        System.out.println(a==b);

    }
}

true

由于jvm具有String 常量池缓存的功能,所以我们看到以上打印的结果为true。
接着我们继续看

package com.company;

class User {
    public void getUserInfo(String value) {
        synchronized (value) {
            while (true){
                System.out.println("当前线程:" + Thread.currentThread().getName());
            }

        }
    }

}
package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo("AA");
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo("AA");
    }
}
当前线程:A
当前线程:A
当前线程:A
当前线程:A
当前线程:A
当前线程:A
当前线程:A

上面的例子,我们通过synchronized 关键字锁住的是传入的String变量,由于A线程和B线程传入的都是AA,JVM具有String常量池缓存功能,因此可知他们传入的都是同一个对象,所以A线程先拿到锁的时候,锁住了传入的变量AA,而B线程传入的也是AA,同一个对象。而该对象被A锁住了,并且里面执行了一个死循环,所以一直没有释放锁。因此B线程一直不能执行。我们修改一下代码

package com.company;

class User {
    public void getUserInfo(Object value) {
        synchronized (value) {
            while (true){
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("当前线程:" + Thread.currentThread().getName());
            }

        }
    }

}
package com.company;

class AThread extends Thread {
    private User user;

    public AThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo(new Object());
    }
}
package com.company;

class BThread extends Thread {
    private User user;

    public BThread(User user) {
        this.user = user;
    }
    @Override
    public void run() {
        user.getUserInfo(new Object());
    }
}
当前线程:B
当前线程:A
当前线程:B
当前线程:A
当前线程:B
当前线程:A
当前线程:B
当前线程:A
当前线程:B
当前线程:A

我们通过new Object的形式传入了Object对象,由于A线程和B线拿到是各自不同的锁,因此A线程和B线程是异步执行的。

2.4、死锁

我们先来看一段代码

package com.company;

public class Main {
    public static void main(String[] args) {
        MyRunnable myRunnable1 = new MyRunnable();
        myRunnable1.setName("a");
        new Thread(myRunnable1).start();
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        myRunnable1.setName("b");
        new Thread(myRunnable1).start();
    }
}

class MyRunnable implements Runnable {
    private String name;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void setName(String name) {
        this.name = name;
    }

    @Override
    public void run() {
        if ("a".equals(name)) {
            synchronized (lock1) {
                System.out.println("当前name = " + name);
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println("执行lock1完执行lock2");
                }
            }
        }
        if ("b".equals(name)) {
            synchronized (lock2) {
                System.out.println("当前name = " + name);
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println("执行lock2完执行lock1");
                }
            }
        }
    }
}
当前name = a
当前name = b

A线程先启动之后,拿到了lock1对象的锁,接着休眠1000毫秒,这个时候B线程已经启动拿到了lock2对象的锁,也休眠了1000毫秒,这个时候A线程休眠结束后要拿到lock2的锁执行完毕,才能释放lock1,而B线程也要拿到lock1执行完毕才能释放lock2,也就是A线程和B线程都在等待拿到对方的锁执行完才能释放自己的锁。所以就会造成死锁,所以A线程和B线程最终都没法执行完成。我们通过cmd命令。进入到jdk的bin目录输入jps命令

image.png

然后看到运行的id是6128,我们使用jstack -l 6128命令,看到了死锁的信息。


image.png

3、volatile关键字

我们先来看一段代码

package com.company;

public class MyTest {
    public static void main(String[] args) {
        MyThead t = new MyThead();
        t.start();
        while (true) {
            if (t.isFlag()) {
                System.out.println("当前线程-" + Thread.currentThread().getName() + " 有点东西...");
            }
        }
    }
}

class MyThead extends Thread {
    private boolean flag = false;
    public boolean isFlag() {
        return flag;
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("当前线程-" + Thread.currentThread().getName() + " flag = " + flag);
    }
}

当前线程-Thread-0 flag = true

以上打印的结果中只有flag = true,而 “有点东西”几个字样一直没打印,我们分析代码理论上MyThead 的 run方法中休眠了1S之后修改了成员变量flag = true值,而在Main函数也就是main线程中通过MyThead的实例去拿flag的值也应该是true,接着跳出循环打印“有点东西”。但是为什么一直没有执行呢?我们先看一张图:


image.png
在jvm的内存模型中,所有的共享变量都存在于主内存中,这里说的共享变量指的的成员变量,因为局部变量是线程私有的。每一个线程都有自己的工作内存,线程之间不能之间访问对方的工作内存,线程之间变量值的传递通过主内存作为桥梁。也就是在上面的例子中,main线程首先从主内存中读取flag的数据拷贝到自己的工作内存,因为此时MyThead 休眠了1S,还没有更改flag的值,所以flag的值还是false,接着main线程一直从自己的工作内存读取flag的值,所以一直是false,尽管1S之后,MyThead 线程更改了flag的值为true,MyThead 修改了flag的值之后,修改了自己的工作内存并同步到主内存中,此时主内存的flag 的值是true,但是在main线程中一直读的flag值还是自己工作内存中的值,一直为false,所以一直没法跳出循环。

解决办法

1、使用synchronized
当前线程-Thread-0 flag = true
当前线程-main 有点东西...
当前线程-main 有点东西...
当前线程-main 有点东西...
当前线程-main 有点东西...

当前线程-Thread-0 flag = true
当前线程-main 有点东西...
当前线程-main 有点东西...
当前线程-main 有点东西...
当前线程-main 有点东西...

使用synchronized关键字,当main线程执行synchronized代码快的时候,拿到锁,接着清空自己的工作内存,从主线程把数据拷贝到自己的工作内存中,所以拿到的值就是最新的flag = true。如果对数据有修改的话,会将工作内存中的数据重新刷回主内存中。然后释放锁。因为其他线程拿不到锁一直处于等待的状态,所以主内存中的数据一直是最新的。

2、Volatile修饰共享变量
package com.company;

public class MyTest {
    public static void main(String[] args) {
        MyThead t = new MyThead();
        t.start();
        while (true) {
                if (t.isFlag()) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("当前线程-" + Thread.currentThread().getName() + " 有点东西...");
                }
        }
    }
}

class MyThead extends Thread {
    volatile private boolean flag = false;

    public boolean isFlag() {
        return flag;
    }

    public void setFlag(boolean flag) {
        this.flag = flag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("当前线程-" + Thread.currentThread().getName() + " flag = " + flag);
    }
}

使用volatile 修饰共享变量flag 也能解决该问题,Volatile做了啥?Volatile修饰的变量,保证了线程读取该变量的时候都是从主内存中读取的,也就是线程共享变量可见性。当MyThead 线程修改了flag 值之后刷新了主内存,因为是用Volatile修饰的变量,所以main线程读取也是从主内存读取,因此读出的flag值是最新的true。这一特性成为可见性。也就是Volatile提供了可见性。那么Volatile是否具有原子性呢?(原子性也就是一致性,线程是否安全,共享变量是否同步),在之前我们知道解决线程安全问题我们使用synchronized关键字,而Volatile是否能做到呢?做不到。

package com.company;

public class MyTest {

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new AThread().start();
        }
    }
}

class AThread extends Thread {
    volatile public static int count;

    @Override
    public void run() {
        add();
    }

    private static void add() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println(Thread.currentThread().getName()+":"+count);
    }
}

Thread-1:300
Thread-6:700
Thread-5:600
Thread-4:500
Thread-3:300
Thread-2:500
Thread-0:300
Thread-9:1000

我们启动了100个线程对共享变量count进行操作,结果打印的时候,有重复的,说明volatile 不具有原子性,也就是线程不安全的。我们把代码加上解决了这一问题。

  synchronized private static void add() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println(Thread.currentThread().getName()+":"+count);
    }
  synchronized private static void add() {
        for (int i = 0; i < 100; i++) {
            count++;
        }
        System.out.println(Thread.currentThread().getName()+":"+count);
    }

总结:

上一篇下一篇

猜你喜欢

热点阅读