二十五、多线程基础(3)线程同步


第1关:使用synchronized关键字同步线程


任务描述
本关任务:使右侧代码中的insert方法在同一时刻只有一个线程能访问。

相关知识
为了完成本关任务,你需要掌握:

1.并发编程什么时候会出现安全问题;

2.怎么解决线程安全问题;

3.synchronized关键字。

并发编程什么时候会出现安全问题
在单线程的时候是不会出现安全问题的,不过在多线程的情况下就很有可能出现,比如说:多个线程同时访问同一个共享资源,多个线程同时向数据库插入数据,这些时候如果我们不做任何处理,就很有可能出现数据实际结果与我们预期的结果不符合的情况。

在这里插入图片描述

现在有两个线程同时获取用户输入的数据,然后将数据插入到同一张表中,要求不能出现重复的数据。

我们必然要在插入数据的时候进行如下操作:

检查数据库中是否存在该数据;

如果存在则不插入,否则插入。

现在有两个线程ThreadA和ThreadB来对数据库进行操作,当某个时刻,线程A和B同时读取到了数据X,这个时候他们都去数据库验证X是否存在,得到的结果都是不存在,然后A、B线程都向数据库插入了X数据,这个时候数据库中出现了两条X数据,还是出现了数据重复。

这个就是线程安全问题,多个线程同时访问一个资源时,会导致程序运行结果并不是想看到的结果。

这里面,这个资源被称为:临界资源(也可以叫共享资源)。

当多个线程同时访问临界资源(一个对象,对象中的属性,一个文件,一个数据库等等)时,就有可能产生线程安全问题。

当多个线程执行一个方法时,方法内部的局部变量并不是临界资源,因为方法是在栈上执行的,而Java栈是线程私有的,因此不会产生线程安全问题。

如何解决线程安全问题
怎么解决线程的安全问题呢?

基本上所有解决线程安全问题的方式都是采用“序列化临界资源访问”的方式,即在同一时刻只有一个线程操作临界资源,操作完了才能让其他线程进行操作,也称作同步互斥访问。

在Java中一般采用synchronized和Lock来实现同步互斥访问。

synchronized关键字
首先我们先来了解一下互斥锁,互斥锁:就是能达到互斥访问目的的锁。

如果对一个变量加上互斥锁,那么在同一时刻,该变量只能有一个线程能访问,即当一个线程访问临界资源时,其他线程只能等待。

在Java中,每一个对象都有一个锁标记(monitor),也被称为监视器,当多个线程访问对象时,只有获取了对象的锁才能访问。

在我们编写代码的时候,可以使用synchronized修饰对象的方法或者代码块,当某个线程访问这个对象synchronized方法或者代码块时,就获取到了这个对象的锁,这个时候其他对象是不能访问的,只能等待获取到锁的这个线程执行完该方法或者代码块之后,才能执行该对象的方法。

我们来看个示例进一步理解synchronized关键字:

public class Example {
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
        new Thread() {
            public void run() {
                insertData.insert(Thread.currentThread());
            };
        }.start();
    }  
}
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public void insert(Thread thread){
        for(int i=0;i<5;i++){
            System.out.println(thread.getName()+"在插入数据"+i);
            arrayList.add(i);
        }
    }
}

这段代码的执行是随机的(每次结果都不一样):

Thread-0在插入数据0
Thread-1在插入数据0
Thread-1在插入数据1
Thread-1在插入数据2
Thread-1在插入数据3
Thread-1在插入数据4
Thread-0在插入数据1
Thread-0在插入数据2
Thread-0在插入数据3
Thread-0在插入数据4

现在我们加上synchronized关键字来看看执行结果:

public synchronized void insert(Thread thread){
     for(int i=0;i<5;i++){
        System.out.println(thread.getName()+"在插入数据"+i);
        arrayList.add(i);
    }
}

输出:

Thread-0在插入数据0
Thread-0在插入数据1
Thread-0在插入数据2
Thread-0在插入数据3
Thread-0在插入数据4
Thread-1在插入数据0
Thread-1在插入数据1
Thread-1在插入数据2
Thread-1在插入数据3
Thread-1在插入数据4

可以发现,线程1会等待线程0插入完数据之后再执行,说明线程0和线程1是顺序执行的。

从这两个示例中,我们可以知道synchronized关键字可以实现方法同步互斥访问。

在使用synchronized关键字的时候有几个问题需要我们注意:

在线程调用synchronized的方法时,其他synchronized的方法是不能被访问的,道理很简单,一个对象只有一把锁;

当一个线程在访问对象的synchronized方法时,其他线程可以访问该对象的非synchronized方法,因为访问非synchronized不需要获取锁,是可以随意访问的;

如果一个线程A需要访问对象object1的synchronized方法fun1,另外一个线程B需要访问对象object2的synchronized方法fun1,即使object1和object2是同一类型),也不会产生线程安全问题,因为他们访问的是不同的对象,所以不存在互斥问题。

synchronized代码块
synchronized代码块对于我们优化多线程的代码很有帮助,首先我们来看看它长啥样:

synchronized(synObject) {
}
当在某个线程中执行该段代码时,该线程会获取到该对象的synObject锁,此时其他线程无法访问这段代码块,synchronized的值可以是this代表当前对象,也可以是对象的属性,用对象的属性时,表示的是对象属性的锁。

有了synchronized代码块,我们可以将上述添加数据的例子修改成如下两种形式:

class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    public void insert(Thread thread){
        synchronized (this) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入数据"+i);
                arrayList.add(i);
            }
        }
    }
}
class InsertData {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    private Object object = new Object();
    public void insert(Thread thread){
        synchronized (object) {
            for(int i=0;i<100;i++){
                System.out.println(thread.getName()+"在插入数据"+i);
                arrayList.add(i);
            }
        }
    }
}

上述代码就是synchronized代码块添加锁的两种方式,可以发现添加synchronized代码块,要比直接在方法上添加synchronized关键字更加灵活。

当我们用sychronized关键字修饰方法时,这个方法只能同时让一个线程访问,但是有时候很可能只有一部分代码需要同步,而这个时候使用sychronized关键字修饰的方法是做不到的,但是使用sychronized代码块就可以实现这个功能。

并且如果一个线程执行一个对象的非static synchronized方法,另外一个线程需要执行这个对象所属类的static synchronized方法,此时不会发生互斥现象,因为访问static synchronized方法占用的是类锁,而访问非static synchronized方法占用的是对象锁,所以不存在互斥现象。

来看一段代码:

public class Test {
    public static void main(String[] args)  {
        final InsertData insertData = new InsertData();
        new Thread(){
            public void run() {
                insertData.insert();
            }
        }.start(); 
        new Thread(){
            public void run() {
                insertData.insert1();
            }
        }.start();
    }  
}
class InsertData { 
    public synchronized void insert(){
        System.out.println("执行insert");
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackT\frace();
        }
        System.out.println("执行insert完毕");
    }
    public synchronized static void insert1() {
        System.out.println("执行insert1");
        System.out.println("执行insert1完毕");
    }
}

执行结果:

执行insert
执行insert1
执行insert1完毕
执行insert完毕

编程要求
请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充,具体任务如下:

使num变量在同一时刻只能有一个线程可以访问。
测试说明
使程序的输出结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

代码实现

package step2;
public class Task {
	public static void main(String[] args) {
		final insertData insert = new insertData();
		for (int i = 0; i < 3; i++) {
			new Thread(new Runnable() {
				public void run() {
					insert.insert(Thread.currentThread());
				}
			}).start();
		}		
		
	}
}
class insertData{
	public static int num =0;
	/********* Begin *********/
	public synchronized void insert(Thread thread){
		for (int i = 0; i <= 5; i++) {
			num++;
			System.out.println(num);
		}
	}
	/********* End *********/
}

第2关:使用线程锁(Lock)实现线程同步


任务描述
本关任务:使用Lock,实现对于某一块代码的互斥访问。

相关知识
上一关我们谈到了synchronized关键字,synchronized关键字主要用来同步代码,实现同步互斥访问,也就是在同一时刻只能有一个线程访问临界资源。从而解决线程的安全问题。

如果一个方法或者代码块被synchronized关键字修饰,当线程获取到该方法或代码块的锁,其他线程是不能继续访问该方法或代码块的。

而其他线程要能访问该方法或代码块,就必须要等待获取到锁的线程释放这个锁,而在这里释放锁只有两种情况:

线程执行完代码块,自动释放锁;

程序报错,jvm让线程自动释放锁。

可能会有一种情况,当一个线程获取到对象的锁,然后在执行过程中因为一些原因(等待IO,调用sleep方法)被阻塞了,这个时候锁还在被阻塞的线程手中,而其他线程这个时候除了等之外,没有任何办法,我们想一想这样子会有多影响程序的效率。

synchronized是Java提供的关键字,使用起来非常方便,不过在有些情况下,它是具有很多局限性的。

因此就需要有一种机制可以不让等待的线程一直无期限地等待下去(比如只等待一定的时间或者能够响应中断),通过Lock就可以办到。

在比如,当多个线程操作同一个文件的时候,同时读写是会冲突的,同时写也是会冲突的,但是同时读是不会发生冲突的,而我们如果用synchronized来实现同步,就会出现一个问题:

如果多个线程都只是进行读操作,所以当一个线程在进行读操作时,其他线程只能等待无法进行读操作。

因此就需要一种机制来使得多个线程都只是进行读操作时,线程之间不会发生冲突,而通过Lock就可以办到。

总的来说Lock要比synchronized提供的功能更多,可定制化的程度也更高,Lock不是Java语言内置的,而是一个类。

Lock接口
我们来了解一下反复提到的Lock,首先我们来查看它的源码:

public interface Lock {
    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

可以发现Lock是一个接口,其中:lock()、tryLock()、tryLock(long time, TimeUnit unit)和lockInterruptibly()方法是用来获取锁的,unlock()方法是用来释放锁的。

首先lock()方法是平常使用得最多的一个方法,就是用来获取锁。如果锁已被其他线程获取,则进行等待。

由于在前面讲到如果采用Lock,必须主动去释放锁,并且在发生异常时,不会自动释放锁。因此一般来说,使用Lock必须在try{}catch{}块中进行,并且将释放锁的操作放在finally块中进行,以保证锁一定被被释放,防止死锁的发生。

一个使用Lock的例子:

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

tryLock()顾名思义,是用来尝试获取锁的,并且该方法有返回值,表示获取成功与否,获取成功返回true,失败返回false,从方法可以发现,该方法如果没有获取到锁时不会继续等待的,而是会直接返回值。

tryLock()的重载方法tryLock(long time, TimeUnit unit)功能类似,只是这个方法会等待一段时间获取锁,如果过了等待时间还未获取到锁就会返回false,如果在等待时间之内拿到锁则返回true。

所以我们经常这样使用:

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

lock()方法的正确使用
因为Lock是一个接口所以我们在编程时一般会使用它的实现类,ReentrantLock是Lock接口的一个实现类,意思是“可重入锁”,接下来我们通过一个例子来学习lock()方法的正确使用方式。

示例1:

public class Test {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    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 = new ReentrantLock();    //注意这个地方
        lock.lock();
        try {
            System.out.println(thread.getName()+"得到了锁");
            for(int i=0;i<5;i++) {
                arrayList.add(i);
            }
        } catch (Exception e) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }
}

输出:

Thread-1得到了锁
Thread-0得到了锁
Thread-0释放了锁
Thread-1释放了锁

结果可能出乎你的意料,不对呀,按道理应该是一个线程得到锁其他线程不能获取锁了的啊,为什么会这样呢?是因为insert()方法中lock变量是一个局部变量。THread-0和Thread-1获取到的是不同的锁,这样不会造成线程的等待。

那怎么才能利用lock()实现同步呢?相信你已经想到了,只要将Lock定义成全局变量就可以了。

public class Test {
    private ArrayList<Integer> arrayList = new ArrayList<Integer>();
    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) {
            // TODO: handle exception
        }finally {
            System.out.println(thread.getName()+"释放了锁");
            lock.unlock();
        }
    }
}

结果:

Thread-0得到了锁
Thread-0释放了锁
Thread-1得到了锁
Thread-1释放了锁

这样就是我们预期的结果了。

很多时候我们为了提高程序的效率不希望线程为了等待锁而一直阻塞,这个时候可以使用tryLock()可以达到目的。

示例,将之前的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) {
                // TODO: handle exception
            }finally {
                System.out.println(thread.getName()+"释放了锁");
                lock.unlock();
            }
        } else {
            System.out.println(thread.getName()+"获取锁失败");
        }
}

输出:

Thread-0得到了锁
Thread-1获取锁失败
Thread-0释放了锁

编程要求
请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充。
测试说明

使得程序输出如下结果(因为线程的执行顺序是随机的可能需要你评测多次):

Thread-0得到了锁
1
2
3
4
5
Thread-0释放了锁
Thread-1得到了锁
6
7
8
9
10
Thread-1释放了锁
Thread-2得到了锁
11
12
13
14
15
Thread-2释放了锁

代码实现


package step3;
 
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
 
public class Task {
    public static void main(String[] args) throws InterruptedException {
        final Insert insert = new Insert();
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                insert.insert(Thread.currentThread());
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                insert.insert(Thread.currentThread());
            }
        });
        Thread t3 = new Thread(new Runnable() {
            public void run() {
                insert.insert(Thread.currentThread());
            }
        });
        // 设置线程优先级
        /*
        t1.setPriority(Thread.MAX_PRIORITY);
        t2.setPriority(Thread.NORM_PRIORITY);
        t3.setPriority(Thread.MIN_PRIORITY);
        t1.start();
        t2.start();
        t3.start();
        */
        t1.start();
        t2.sleep(500);
        t2.start();
        t3.sleep(1000);
        t3.start();
    }
}
 
class Insert {
    public static int num;
    // 在这里定义Lock
    private Lock lock = new ReentrantLock();
 
    public void insert(Thread thread) {
        /********* Begin *********/
        if (lock.tryLock()) {
            try {
                System.out.println(thread.getName() + "得到了锁");
                for (int i = 0; i < 5; i++) {
                    num++;
                    System.out.println(num);
                }
            } finally {
                System.out.println(thread.getName() + "释放了锁");
                lock.unlock();
            }
        } else {
            System.out.println(thread.getName() + "获取锁失败");
        }
    }
    /********* End *********/
}


第3关:使用volatile实现变量的可见性


任务描述
本关任务:使用volatile关键字与同步实现右侧程序输出10000。

相关知识
在并发编程中,volatile关键字扮演着非常重要的作用,接下来我们直接进入主题。

什么是 volatile 关键字
volatile是干啥用的,有什么含义和特点呢?

当一个共享变量被volatile修饰时,它就具备了“可见性”,即这个变量被一个线程修改时,这个改变会立即被其他线程知道。

当一个共享变量被volatile修饰时,会禁止“指令重排序”。

先来看个例子:

//线程1
boolean stop = false;
while(!stop){
    doSomething();
}
//线程2
stop = true;

因为线程是不直接提供停止的方法的,所以我们很多时候想要中断线程一般都会采用上述代码。

不过这段代码是存在问题的,当线程2执行时,这段代码能保证一定能中断线程1吗?在大多数情况下是可以的,不过也有可能不能中断线程1。

为何有可能导致无法中断线程呢?每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。

那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。

如何避免这种情况呢?

很简单,给stop变量加上volatile关键字就可以了。

volatile关键字会产生什么效果呢?

使用volatile关键字会强制将变量的修改的值立即写至主内存;

使用volatile关键字,当线程2对变量stop修改时,会强制将所有用到stop变量的线程对应的缓存中stop的缓存行置为无效。

由于线程1的stop缓存行无效,所以在运行时线程1会读取主存中stop变量的值。

所以到最后线程1读取到的就是stop最新的值。

volatile可以保证原子性吗
在之前我们了解到了线程的三大特性:原子性,可见性,有序性。

前面的例子我们知道了volatile可以保证共享变量的可见性,但是volatile可以保证原子性吗?

我们来看看:

public class Test {
    public volatile int inc = 0;
    public void increase() {
        inc++;
    }
    public static void main(String[] args) {
        final Test test = new Test();
        for(int i=0;i<10;i++){
            new Thread(){
                public void run() {
                    for(int j=0;j<1000;j++)
                        test.increase();
                };
            }.start();
        }
        while(Thread.activeCount()>1)  //保证前面的线程都执行完
            Thread.yield();
        System.out.println(test.inc);
    }
}

来想一想这段程序的输出结果,然后copy到本地运行看一看效果。

可能我们想的结果应该是:10000,不过最终运行的结果往往达不到10000,可能我们会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000。

前面我们讲到volatile是可以保证可见性的,不过上述程序的错误在于volatile没法保证程序的原子性。

我们知道变量的自增不是原子性的。它包括两个步骤:

1.读取变量的值;

2.给变量的值加1并写入工作内存。

我们想象这样一种情况:线程1在操作inc变量自增的时候可能会遇到这种状况,读取到了inc变量的值,这个时候inc的值为10,还没有进行自增操作时候线程1阻塞了,紧接着线程2对inc变量进行操作,注意这个时候inc的值还是10,线程2对inc进行了自增操作,这个时候inc的值是11,并将这个改变写到主存中,好了,现在线程1恢复了,它并不会去主存中读取inc的值,因为inc已经在它的缓存中了,所以继续进行之前的操作,注意这个时候线程1的缓存中inc的值是10,线程1对inc的值进行加1.inc等于11,然后写入主存。

我们发现两个线程都对inc进行了一轮操作,但是inc的值只增加了1.

可能我们还是会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则,但是要注意,线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

理解了这些我们就能明白,这个问题的根源就在于自增操作不是原子性的。

而要解决这个问题就很简单了,让自增操作变成原子性就可以了。

怎么保证原子性呢,怎么让上述代码结果是10000呢?

用我们前两关学习的知识就可以啦,具体代码自己思考吧,毕竟从自己脑子里想出来的才是自己的呢。

编程要求
请仔细阅读右侧代码,根据方法内的提示,在Begin - End区域内进行代码补充。
测试说明

预期输出:10000。

提示:可以使用两种方式实现原子性,所以本关有多种方式都可以通关。

代码实现

package step4;
public class Task {
public volatile int inc = 0;
//请在此添加实现代码
/********** Begin **********/
public synchronized void increase() {
		inc++;
}
/********** End **********/
	public static void main(String[] args) {
		final Task test = new Task();
		for (int i = 0; i < 10; i++) {
			new Thread() {
				public void run() {
					for (int j = 0; j < 1000; j++)
						test.increase();
				};
			}.start();
		}
		while (Thread.activeCount() > 1) // 保证前面的线程都执行完
			Thread.yield();
		System.out.println(test.inc);
	}
}
  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

请你吃茶去

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值