Java 多线程编程核心技术 (二):对象及变量的并发访问(上)

阅读数:3580 2019 年 8 月 29 日 09:30

Java多线程编程核心技术(二):对象及变量的并发访问(上)

本文经授权转载自微信公众号薛勤的博客,主要介绍 Java 多线程中的同步,也就是如何在 Java 语言中写出线程安全的程序,如何在 Java 语言中解决非线程安全的相关问题。

阅读本文应该着重掌握如下技术点:

  • synchronized 对象监视器为 Object 时的使用。

  • synchronized 对象监视器为 Class 时的使用。

  • 非线程安全是如何出现的。

  • 关键字 volatile 的主要作用。

  • 关键字 volatile 与 synchronized 的区别及使用情况。

1.synchronized 同步方法

“非线程安全”其实会在多个线程对同一个对象中的实例变量进行并发访问时发生,产生的后果就是”脏读“,也就是读取到的数据其实是被更改过的。而“线程安全”就是已获得的实例变量的值是经过线程同步处理的,不会出现脏读的现象。

1.1 方法内的变量为线程安全

“非线程安全”问题存在于“实例变量”中,如果是方法内部的私有变量,则不存在“非线程安全”问题,所得结果也就是“线程安全”的了。

1.2 实例变量的非线程安全

如果多个线程共同访问 1 个对象中的实例变量,则有可能出现“非线程安全”问题。

用线程访问的对象中如果有多个实例对象,则运行的结果有可能出现交叉的情况。

如果对象仅有一个实例变量,则有可能出现覆盖的情况。

如果两个线程同时访问一个没有同步的方法,如果两个线程同时操作业务对象中的实例变量,则有可能出现“非线程安全”问题。解决这个问题的方法就是在方法前加关键字 synchronized 即可。

1.3 多个对象多个锁

代码示例:

复制代码
public class Run {
public static void main(String[] args) {
MyService service1 = new MyService();
Thread thread1 = new Thread(service1);
thread1.start();
MyService service2 = new MyService();
Thread thread2 = new Thread(service2);
thread2.start();
}
}
public class MyService implements Runnable {
private int i = 0;
@Override
synchronized public void run() {
System.out.println(++i);
}
}

上面示例是两个线程分别访问同一个类的两个不同实例的相同的同步方法,效果却是以异步的方式运行的。本示例由于创建了 2 个业务对象,在系统中产生出 2 个锁,所以运行结果是异步的,打印的效果就是 1 1。当我们把线程 2 的参数 service2 改成 service1,打印结果变为 1 2。为什么是这样的结果?

关键字 synchronized 取得的线程对象都是对象锁,而不是把一段代码或方法(函数)当做锁,所以在上面的示例中,哪个线程先执行带 synchronized 关键字的方法,哪个线程就持有该方法所属对象的锁 Lock,那么其他线程只能呈等待状态,前提是多个线程访问的是同一个对象。

但如果多个线程访问多个对象,则 JVM 会创建多个锁。

1.4 synchronized 方法与锁对象

为了证明前面讲的线程锁是对象,示例代码如下:

复制代码
public class MyService implements Runnable {
@Override
public void run() {
System.out.println("begin: "+Thread.currentThread().getName());
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end");
}
}
public class Run {
public static void main(String[] args) {
MyService service = new MyService();
Thread thread1 = new Thread(service,"A");
thread1.start();
Thread thread2 = new Thread(service,"B");
thread2.start();
}
}

运行结果:

begin: B
begin: A
end
end

在 run 方法前加入关键字 synchronized 进行同步处理。再次运行结果如下:

begin: A
end
begin: B
end

通过上面的实验得出结论,调用关键字 synchronized 声明的方法一定是排队运行的。另外需要牢牢记住“共享”这两个字,只有共享资源读写访问才需要同步化,如果不是共享资源,那么基本就没有同步的必要。

1.5 脏读

复制代码
public class MyService{
private String username = "AA";
private String password = "aa";
public void getValue() {
System.out.println(Thread.currentThread().getName()+" : "+username+" "+password);
}
synchronized public void setValue(String username,String password){
this.username = username;
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
this.password = password;
}
public static void main(String[] args) throws InterruptedException {
MyService service = new MyService();
Thread thread1 = new Thread(() -> service.setValue("BB","bb"),"Thread-A");
thread1.start();
Thread.sleep(200);
Thread thread2 = new Thread(service::getValue,"Thread-B");
thread2.start();
}
}

打印结果:

Thread-B : BB aa

出现脏读是因为 getValue 方法不是同步的,所以可以在任意时候进行调用。解决方法就是加上同步 synchronized 关键字,代码如下:

复制代码
synchronized public void getValue() {
System.out.println(Thread.currentThread().getName()+" : "+username+" "+password);
}

运行结果:

Thread-B : BB bb

通过上述示例不仅要知道脏读是通过 synchronized 关键字解决的,还要知道如下内容:

当 A 线程调用实例对象的加入 synchronized 关键字的 X 方法时,A 线程就获得了 X 方法锁,更准确地讲,是获得了对象的锁,所以其他线程必须等 A 线程执行完毕了才可以调用 X 方法,但 B 线程可以随意调用其他的非 synchronized 同步方法。

脏读一定会出现操作实例变量的情况下,这就是不同线程“争抢”实例变量的结果。

1.6 synchronized 锁重入

关键字 synchronized 拥有锁重入的功能,也就是在使用 synchronized 时,当一个线程得到一个对象后,再次请求此对象锁时是可以再次得到该对象的锁的。这也证明了在一个 synchronized 方法 / 块的内部调用本类的其他 synchronized 方法 / 块,是永远可以得到锁的。

示例代码:

复制代码
public class MyService{
synchronized public void service1(){
System.out.println("service1");
service2();
}
synchronized public void service2(){
System.out.println("service2");
}
}

“可重入锁”的概念是:自己可以再次获取自己的内部锁。可重入锁也支持在父子类继承的环境中。

示例代码:

复制代码
public class MyServiceChild extends MyService{
synchronized public void service(){
System.out.println("service1");
this.service2();
}
}

说明子类是完全可以通过“可重入锁”调用父类的同步方法的。

1.7 出现异常, 锁自动释放

当一个线程执行的代码出现异常时,其所持有的锁会自动释放。

1.8 同步不具有继承性

同步不可以继承。子类继承父类的同步方法时还需要添加 synchronized 关键字才能保持同步。

2.synchronized 同步语句块

用关键字 synchronized 声明方法在某些情况下是有弊端的,比如 A 线程调用同步方法执行一个长时间的任务,那么 B 线程则必须等待比较长的时间。在这样的情况下可以使用 synchronized 同步语句块来解决。synchronized 方法是对当前对象进行加锁,而 synchronized 代码块是对某一个对象进行加锁。

2.1 synchronized 同步代码块的使用

当两个并发线程访问同一个对象 object 中的 synchronized(this) 同步代码块时,一段时间内只能有一个线程被执行,另一个线程必须等待当前线程执行完这个代码块以后才能执行该代码块。

示例代码:

复制代码
public class Test {
public void service(){
synchronized (this) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537000799741
Thread-A end: 1537000802742
Thread-B begin: 1537000802742
Thread-B end: 1537000805742

上述示例证明了同步 synchronized 代码块真的是同步的。

2.2 一半同步,一半异步

我们把前面的示例代码的 service 方法改造一下:

复制代码
public void service(){
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
synchronized (this) {
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}

再次运行:

Thread-A begin: 1537001008952
Thread-B begin: 1537001008952
Thread-A end: 1537001011953
Thread-B end: 1537001014954

本实验说明:不在 synchronized 代码块中就是异步执行,在 synchronized 块中就是同步执行。

2.3 synchronized 代码块间的同步性

在使用 synchronized(this) 代码块需要注意的是,当一个线程访问 object 的一个 synchronized(this) 同步代码块时,其它线程对同一个 object 中所有其他 synchronized(this) 同步访问被阻塞,这说明 synchronized 使用的“对象监视器”是一个。

和 synchronized 关键字修饰的方法一样,synchronize(this) 代码块也是锁定的当前对象。

2.4 将任意对象作为对象监视器

多个线程调用同一个对象中得不同名称的 synchronized 同步方法或 synchronized(this) 同步代码块时,调用的效果就是按顺序执行,也就是同步的,阻塞的。

这说明 synchronized 同步方法或 synchronized 同步代码块分别有两种作用。

(1)对其他 synchronized 同步方法或 synchronized(this) 同步代码块调用呈阻塞状态。

(2)同一时间只有一个线程可以执行 synchronized 同步方法或 synchronized(this) 同步代码块中的代码。

在前面我们使用 synchronized(this) 格式来同步代码块,其实 Java 还支持对“任意对象”作为“对象监视器”来实现同步的功能。这个”任意对象“大多数是实例变量及方法的参数,使用格式为 synchronized(非 this 对象)。

根据前面对 synchronized(this) 同步代码块的作用总结可知,synchronized(非 this 对象) 格式的作用只有 1 种:synchronized(非 this 对象 X ) 同步代码块。

(1)在多个线程持有”对象监视器“为同一个对象的前提下,同一时间只有一个线程可以执行 synchronized(非 this 对象 X) 同步代码块。

(2)当持有”对象监视器“为同一个对象的前提下,同一时间只有一个线程可以执行 synchronized(非 this 对象 X) 同步代码块中的代码。

下面演示下任意对象作为对象监视器的示例:

复制代码
public class Test {
private String anyObject = new String();
public void service(){
synchronized (anyObject) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537008016172
Thread-A end: 1537008019173
Thread-B begin: 1537008019173
Thread-B end: 1537008022173

锁非 this 对象具有一定的优点:如果在一个类中有很多个 synchronized 方法,这时虽然能实现同步,但会受到阻塞,所以影响运行效率;但如果使用同步代码块锁非 this 对象,则 synchronized(非 this) 代码块中的程序与同步方法是异步的,不与其他锁 this 同步方法争抢 this 锁,则可大大提高运行效率。

再来看下面的示例代码:

复制代码
public class Test {
private String anyObject = new String();
public void service(){
synchronized (anyObject) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
synchronized public void service2(){
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.service2();
}
},"Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537009027680
Thread-B begin: 1537009027681
Thread-A end: 1537009030680

可见,使用“synchronized(非 this 对象 x) 同步代码块”格式进行同步操作时,对象监视器必须是同一个对象,如果不是同一个对象。如果不是同一个对象监视器,运行的结果就是异步调用了,就会交叉运行。

2.5 细化三个结论

”synchronized(非 this 对象 X)“格式的写法是将 x 对象本身作为“对象监视器”,这样就可以得出以下 3 个结论:

  • 当多个线程同时执行 synchronized(X){}同步代码块时呈同步效果。

  • 当其他线程执行 X 对象中 synchronized 同步方法时呈同步效果。

  • 当其他线程执行 X 对象方法里面的 synchronized(this) 代码块时也呈现同步效果。

  • 但需要注意的是,如果其他线程调用不加 synchronized 关键字的方法时,还是异步调用。

2.6 静态同步 synchronized 方法与 synchronized(class) 代码块

关键字 synchronized 还可以在 static 静态方法上,如果这样写,那是对当前的 *.java 文件对应的 Class 类进行持锁。

下面测试静态同步方法:

复制代码
public class Test2 {
synchronized public static void service() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537011409603
Thread-A end: 1537011412608
Thread-B begin: 1537011412608
Thread-B end: 1537011415608

synchronized 关键字加到 static 静态方法上是给 Class 类上锁,而 synchronized 关键字加到非 static 静态方法上是给对象上锁。

为了验证对象锁和 Class 锁不是同一个锁,来看下面的代码:

复制代码
public class Test2 {
synchronized public static void service() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
synchronized public void service2(){
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
Test2.service();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test2().service2();
}
}, "Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537012019151
Thread-B begin: 1537012019152
Thread-A end: 1537012022152
Thread-B end: 1537012022152

异步的原因是持有不同的锁,一个是对象锁,另外一个是 Class 锁,Class 锁可以对所有类的实例对象起作用。

下面我们测试 synchronized(class) 代码块,示例代码如下:

复制代码
public class Test {
public void service(){
synchronized (Test.class) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service();
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service();
}
},"Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537011197190
Thread-A end: 1537011200191
Thread-B begin: 1537011200191
Thread-B end: 1537011203191

同步 synchronized(class) 代码块的作用其实和 synchronized static 方法的作用一样。

2.7 数据类型 String 的常量池特性

在 JVM 中具有 String 常量池缓存的功能,将 synchronized(String) 同步块与 String 联合使用时,要注意常量池以带来的一些例外。

复制代码
public class Test {
public void service(String str){
synchronized (str) {
while (true) {
System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis());
}
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service("AA");
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service("AA");
}
},"Thread-B").start();
}
}

运行结果:

Thread-A time: 1537013470535
Thread-A time: 1537013470535
Thread-A time: 1537013470535

运行结果显示 A 线程陷入了死循环,而 B 线程一直在等待未执行。出现这样的结果就是两个持有相同的锁,所以造成 B 线程不能执行。这就是 String 常量池带来的问题。因此在大多数情况下,同步 synchronized 代码块都不使用 String 作为锁对象,而改用其他,比如 new Object() 实例化一个 Object 对象,但它并不放入缓存中。

改造后的代码:

复制代码
public class Test {
public void service(Object str){
synchronized (str) {
while (true) {
System.out.println(Thread.currentThread().getName() + " time: " + System.currentTimeMillis());
}
}
}
public static void main(String[] args) {
new Thread(new Runnable() {
@Override
public void run() {
new Test().service(new Object());
}
},"Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
new Test().service(new Object());
}
},"Thread-B").start();
}
}

运行结果:

Thread-A time: 1537015931981
Thread-A time: 1537015931982
Thread-B time: 1537015931982
Thread-B time: 1537015931982

交替打印的原因是持有的锁不是一个。

2.8 同步 synchronized 方法无限等待与解决

同步方法极易造成死循环。示例代码:

复制代码
public class Test {
synchronized public void serviceA() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
boolean is = true;
while (is){
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
synchronized public void serviceB() {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
public static void main(String[] args) {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.serviceA();
}
}, "Thread-A").start();
new Thread(new Runnable() {
@Override
public void run() {
test.serviceB();
}
}, "Thread-B").start();
}
}

线程 B 永远得不到运行的机会,锁死了。

解决的方法就是使用同步块。更改后的代码如下:

复制代码
public class Test {
private Object objectA = new Object();
public void serviceA() {
synchronized (objectA) {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
boolean is = true;
while (is) {
}
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
}
private Object objectB = new Object();
synchronized public void serviceB() {
synchronized (objectB) {
System.out.println(Thread.currentThread().getName() + " begin: " + System.currentTimeMillis());
System.out.println(Thread.currentThread().getName() + " end: " + System.currentTimeMillis());
}
}
....
}

2.9 多线程的死锁

Java 多线程死锁是一个经典问题,因为不同的线程都在等待根本不可能被释放的锁,从而导致所有的任务都无法完成。在多线程技术中,“死锁”是必须避免的,因为这会造成线程的“假死”。

复制代码
public class DealThread implements Runnable {
public String username;
public Object locak1 = new Object();
public Object locak2 = new Object();
public void setFlag(String username){
this.username = username;
}
@Override
public void run() {
if (username.equals("a")){
synchronized (locak1){
System.out.println("username:"+username);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locak2){
System.out.println(" 按 lock1-》lock2 执行 ");
}
}
}
if (username.equals("b")){
synchronized (locak2){
System.out.println("username:"+username);
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (locak1){
System.out.println(" 按 lock2-》lock1 执行 ");
}
}
}
}
public static void main(String[] args) throws InterruptedException {
DealThread dealThread = new DealThread();
dealThread.setFlag("a");
Thread threadA = new Thread(dealThread);
threadA.start();
Thread.sleep(100);
dealThread.setFlag("b");
Thread threadB = new Thread(dealThread);
threadB.start();
}
}

运行结果,出现死锁:

username:a
username:b

死锁是程序设计的 Bug,在设计程序时就需要避免双方互相持有对方的锁的情况。需要说明的是,本实验使用 synchronized 嵌套的代码结构来实现死锁,其实不使用嵌套的代码结构也会出现死锁,与嵌套不嵌套无任何关系,不要被代码结构所误导。只要互相等待对方释放锁就有可能出现死锁。

可以使用 JDK 自带的工具来检测是否有死锁的现象。首先进入 CMD 命令行界面,再进入 JDK 的安装文件夹中的

bin 目录,执行 jps 命令。得到运行的线程 Run 的 id 值。再执行 jstack 命令,查看结果。

完整命令演示如下:

复制代码
D:\Java\jdk1.8.0\bin>jps
8240 Launcher
13252 Jps
12312
7948 DealThread
D:\Java\jdk1.8.0\bin>jstack -l 7948
....
Java stack information for the threads listed above:
===================================================
"Thread-1":
at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:39)
- waiting to lock <0x00000000d6089e80> (a java.lang.Object)
- locked <0x00000000d6089e90> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
"Thread-0":
at cn.zyzpp.thread2_3.DealThread.run(DealThread.java:25)
- waiting to lock <0x00000000d6089e90> (a java.lang.Object)
- locked <0x00000000d6089e80> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
Found 1 deadlock.

2.10 锁对象的改变

在任何数据类型作为同步锁时,需要注意的是,是否有多个线程同时持有锁对象,如果同时持有锁对象,则这些线程之间就是同步的;如果分别获得锁对象,这些线程之间就是异步的。

复制代码
public class Test {
private String lock = "123";
public void service(){
synchronized (lock) {
System.out.println(Thread.currentThread().getName()+" begin: " + System.currentTimeMillis());
lock = "456";
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+" end: " + System.currentTimeMillis());
}
}
public static void main(String[] args) throws InterruptedException {
Test test = new Test();
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-A").start();
Thread.sleep(50);
new Thread(new Runnable() {
@Override
public void run() {
test.service();
}
},"Thread-B").start();
}
}

运行结果:

Thread-A begin: 1537019992452
Thread-B begin: 1537019992652
Thread-A end: 1537019994453
Thread-B end: 1537019994653

为什么是乱序?因为 50ms 过后线程取得的锁时“456”。

把 lock = "456" 放在 Thread.sleep(2000) 之后,再次运行。

Thread-A begin: 1537020101553
Thread-A end: 1537020103554
Thread-B begin: 1537020103554
Thread-B end: 1537020105558

线程 A 和线程 B 持有的锁都是“123”,虽然将锁改成了“456”,但结果还是同步的,因为 A 和 B 争抢的锁是“123”。

还需要提示一下,只要对象不变,即使对象的属性被改变,运行的结果还是同步的。

评论

发布