Java多线程(下)
线程同步
当多个线程访问一个对象时,有可能会发生污读,即读取到未及时更新的数据,这个时候就需要线程同步。
线程同步:
即当有一个线程在对内存进行操作时,其他线程都不可以对这个内存地址进行操作,直到该线程完成操作, 其他线程才能对该内存地址进行操作,而其他线程又处于等待状态,实现线程同步的方法有很多,临界区对象就是其中一种。
在一般情况下,创建一个线程是不能提高程序的执行效率的,所以要创建多个线程。但是多个线程同时运行的时候可能调用线程函数,在多个线程同时对同一个内存地址进行写入,由于CPU时间调度上的问题,写入数据会被多次的覆盖,所以就要使线程同步。
同步就是协同步调,按预定的先后次序进行运行。如:你说完,我再说。
“同”字从字面上容易理解为一起动作
其实不是,“同”字应是指协同、协助、互相配合。
如进程、线程同步,可理解为进程或线程A和B一块配合,A执行到一定程度时要依靠B的某个结果,于是停下来,示意B运行;B依言执行,再将结果给A;A再继续操作。
所谓同步,就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回,同时其它线程也不能调用这个方法。按照这个定义,其实绝大多数函数都是同步调用(例如sin, isdigit等)。但是一般而言,我们在说同步、异步的时候,特指那些需要其他部件协作或者需要一定时间完成的任务。例如Window API函数SendMessage。该函数发送一个消息给某个窗口,在对方处理完消息之前,这个函数不返回。当对方处理完毕以后,该函数才把消息处理函数所返回的LRESULT值返回给调用者。
在多线程编程里面,一些敏感数据不允许被多个线程同时访问,此时就使用同步访问技术,保证数据在任何时刻,最多有一个线程访问,以保证数据的完整性。
由于同一进程的多个线程共享同一块存储空间,在带来方便的同时,也带来了访问冲突问题,为了保证数据在方法中被访问时的正确性,在访问时加入锁机制synchronized,当一个线程获得对象的排它锁,独占资源,其他线程必须等待,使用后释放锁即可能存在以下问题:
- 一个线程持有锁会导致其他所有需要此锁的线程挂起;
- 在多线程竞争下,加锁,释放锁会导致比较多的上下文切换和调度延时,引
起性能问题; - 如果一个优先级高的线程等待- -个优先级低的线程释放锁会导致优先级倒
置,引起性能问题.
举个例子,一个售票口有10张票,当100个人同时去买时,每个人都获取到了有100张票的数据,所以每个人买了一张,导致最后剩下-90张票,线程不同步就会导致这种结果。
synchronized
synchronized是Java中的关键字,是一种同步锁。它修饰的对象有以下几种:
1. 修饰一个代码块,被修饰的代码块称为同步语句块,其作用的范围是大括号{}括起来的代码,作用的对象是调用这个代码块的对象;
2. 修饰一个方法,被修饰的方法称为同步方法,其作用的范围是整个方法,作用的对象是调用这个方法的对象;
3. 修改一个静态的方法,其作用的范围是整个静态方法,作用的对象是这个类的所有对象;
4. 修改一个类,其作用的范围是synchronized后面括号括起来的部分,作用主的对象是这个类的所有对象。
我们写一个例子,使用线程不安全的List来看看效果
public class MyThread{
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
}
}
可以看到,循环1000次,只存进去998个,重复执行,这个大小还会变化,所以是线程不安全的。
可以使用synchronized把list加锁,就能保证每次都能插入进去。
public class MyThread{
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
new Thread(()->{
synchronized (list) {
list.add(Thread.currentThread().getName());
}
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
}
}
这样就能够保证线程安全。
也可以使用JUC(java.util.concurrent
)包下的线程安全的列表CopyOnWriteArrayList,代码如下
import java.util.concurrent.CopyOnWriteArrayList;
public class MyThread{
public static void main(String[] args) throws InterruptedException {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 1000; i++) {
new Thread(()->{
list.add(Thread.currentThread().getName());
}).start();
}
Thread.sleep(2000);
System.out.println(list.size());
}
}
使用CopyOnWriteArrayList就可以不需要synchronized关键字实现线程安全
查看源代码可以发现,CopyOnWriteArrayList实现了List<E>接口
然后再add方法中使用了synchronized来加锁,和我们上面的操作方法一致
//CopyOnWriteArrayList中的add()方法
public boolean add(E e) {
synchronized (lock) {
Object[] es = getArray();
int len = es.length;
es = Arrays.copyOf(es, len + 1);
es[len] = e;
setArray(es);
return true;
}
}
死锁
所谓死锁,是指多个进程在运行过程中因争夺资源而造成的一种僵局,当进程处于这种僵持状态时,若无外力作用,它们都将无法再向前推进。
死锁的条件
- 互斥条件
- 请求和保持
- 不可抢占
- 循环等待
只要破坏后三个条件之一就可以避免死锁,可以使用银行家算法等方法。
Lock锁
- 从JDK 5.0开始,Java提供了更强大的线程同步机制一通过显式定义同步锁对象来实现同步。同步锁使用Lock对象充当java.util.concurrent.locks.Lock接口是控制多个线程对共享资源进行访问的工具。
- Lock锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程开
始访问共享资源之前应先获得Lock对象 - ReentrantLock类实现了Lock,它拥有与synchronized相同的并发性和内存语义,在实现线程安全的控制中,比较常用的是ReentrantLock,可以显式加锁、释放锁。
先写一个不使用锁的例子
import java.util.concurrent.locks.ReentrantLock;
public class MyThread implements Runnable {
public static void main(String[] args) {
MyThread thread = new MyThread();
Thread thread1 = new Thread(thread);
Thread thread2 = new Thread(thread);
Thread thread3 = new Thread(thread);
thread1.start();
thread2.start();
thread3.start();
}
public static int tickets = 10;
@Override
public void run() {
while (true) {
if (tickets > 0) {
System.out.println(tickets--);
} else {
break;
}
}
}
}
执行后发现顺序完全是乱的
使用ReentrantLock(可重入锁)来把相关代码加锁,即可实现按顺序调用
import java.util.concurrent.locks.ReentrantLock;
public class MyThread implements Runnable {
public static void main(String[] args) {
MyThread thread = new MyThread();
Thread thread1 = new Thread(thread);
Thread thread2 = new Thread(thread);
Thread thread3 = new Thread(thread);
thread1.start();
thread2.start();
thread3.start();
}
public static int tickets = 10;
final ReentrantLock lock = new ReentrantLock();
@Override
public void run() {
while (true) {
try {
lock.lock();
if (tickets > 0) {
System.out.println(tickets--);
} else {
break;
}
} finally {
lock.unlock();
}
}
}
}
这样也可以实现线程同步。
- Lock是显式锁(手动开启和关闭锁,别忘记关闭锁) synchronized是隐式锁,出了
作用域自动释放 - Lock只有代码块锁,synchronized有代码块锁和方法锁
- 使用Lock锁, JVM将花费较少的时间来调度线程,性能更好。并且具有更好的扩展
性(提供更多的子类)。 - 优先使用顺序:
- Lock >同步代码块(已经进入了方法体,分配了相应资源) >同步方法(在方
法体之外)
- Lock >同步代码块(已经进入了方法体,分配了相应资源) >同步方法(在方
线程通信
生产者和消费者问题
- 假设仓库中只能存放一件产品,生产者将生产出来的产品放入仓库,消费者将仓库中产品取走消费。
- 如果仓库中没有产品,则生产者将产品放入仓库,否则停止生产并等待,直到仓库中的产品被消费者取走为止。
- 如果仓库中放有产品,则消费者可以将产品取走消费,否则停止消费并等待,直到仓库中再次放入产品为止。
Java提供的线程通信方法
方法名 | 作用 |
---|---|
wait() | 表示线程一直等待,直到其他线程通知,与sleep不同,会释放锁 |
wait(long timeout) | 指定等待的毫秒数 |
notify() | 唤醒一个处于等待状态的线程 |
notifyAll() | 唤醒同一个对象上所有调用wait()方法的线程,优先级别高的线程优先调度 |
均是0bject类的方法都,只能在同步方法或者同步代码块中使用,否则会抛出llegalMonitorStateException
- 对于生产者,没有生产产品之前,要通知消费者等待.而生产了产品之后,又需要马_上通知消费者消费
- 对于消费者,在消费之后,要通知生产者已经结束消费,需要生产新的产品以供消费
- 在生产者消费者问题中,仅有synchronized是不够的
- synchronized 可阻止并发更新同- -个共享资源,实现了同步
- synchronized 不能用来实现不同线程之间的消息传递(通信)
解决方式一:管程
首先定义一个生产者类
//生产者
class Producer extends Thread {
SynContainer container;
public Producer(SynContainer container) {
this.container = container;
}
//生产
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println("生产第" + i + "个");
container.push(new Product(i));
}
}
}
生产者不断往缓冲区添加产品,然后定义一个消费者类
//消费者
class Consumer extends Thread {
SynContainer container;
public Consumer(SynContainer container) {
this.container = container;
}
//消费
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.println("消费第" + container.pop().id + "个");
try {
Thread.sleep(500);
} catch (InterruptedException ignored) { }
}
}
}
消费者不断在缓冲区去除产品,这里添加一个sleep来模拟真实效果
最后定义缓冲区
//缓冲区
class SynContainer {
//容器大小
Product[] products = new Product[10];
//计数器
int count = 0;
//生产者放入产品
public synchronized void push(Product product) {
//如果满了,通知消费者,生产者等待,否则放入产品
if (count == products.length) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
products[count++] = product;
this.notifyAll();
}
//消费者消费产品
public synchronized Product pop() {
if (count == 0) {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
this.notifyAll();
return products[--count];
}
}
缓冲区的两个方法都是使用synchronized修饰,保证能够执行完整,然后根据容器大小来判断是否让生产者以及消费者线程等待
当容器中没有产品时,通知消费者等待,生产者线程开始,当产品满时,通知生产者等待,消费者线程开始。
最后补上产品类
//产品
class Product {
//产品编号
int id;
public Product(int id) {
this.id = id;
}
}
解决方式二:信号量
类定义和上面类似,只不过在产品类中添加了一个信号量来区分是否有产品,不需要一个缓冲区
//生产者
class Producer extends Thread {
Product product;
public Producer(Product product) {
this.product = product;
}
//生产
@Override
public void run() {
for (int i = 0; i < 10; i++) {
this.product.push("产品" + i);
}
}
}
//消费者
class Consumer extends Thread {
Product product;
public Consumer(Product product) {
this.product = product;
}
//消费
@Override
public void run() {
for (int i = 0; i < 10; i++) {
this.product.pop();
}
}
}
//产品
class Product {
String product;
boolean flag = true;
//生产
public synchronized void push(String product) {
if (!flag) {
try {
this.wait();
} catch (InterruptedException ignored) { }
}
System.out.println("生产了" + product);
//通知消费
this.notifyAll();
this.product = product;
this.flag = !this.flag;
}
//消费
public synchronized void pop() {
if (flag) {
try {
this.wait();
} catch (InterruptedException ignored) { }
}
System.out.println("消费了" + this.product);
//通知生产者
this.notifyAll();
this.flag = !this.flag;
}
}
这样也可以解决生产者和消费者问题
线程池
背景
经常创建和销毁、使用量特别大的资源,比如并发情况下的线程,对性能影响很大。
思路:提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。
优点
- 提高响应速度(减少了创建新线程的时间)
- 降低资源消耗(重复利用线程池中线程,不需要每次都创建)
- 便于线程管理
参数说明
- corePoolSize: 核心池的大小
- maximumPoolSize:最大线程数
- keepAliveTime: 线程没有任务时最多保持多长时间后会终止
JDK 5.0起提供了线程池相关API: ExecutorService和Executors
ExecutorService:真正的线程池接口。常见子类ThreadPoolExecutor
- void execute(Runnable command) :执行任务/命令,没有返回值,-般用来执行Runnable
- <T> Future<T> submit(Callable<T> task):执行任务,有返回值,一-般 又来执行
Callable - void shutdown() :关闭连接池
Executors:工具类、线程池的工厂类,用于创建并返回不同类型的线程池
代码演示
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class Test {
public static void main(String[] args) {
//创建线程池
ExecutorService service = Executors.newFixedThreadPool(10);
service.execute(new MyThread());
service.execute(new MyThread());
service.execute(new MyThread());
//关闭连接
service.shutdown();
}
}
class MyThread implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName());
}
}
这样就可以实现通过线程池来管理线程
总结
- 线程就是独立的执行路径;
- 在程序运行时,即使没有自己创建线程,后台也会有多个线程,如主线程,gc线程;
- main()称之为主线程,为系统的入口,用于执行整个程序;
- 在一个进程中,如果开辟了多个线程,线程的运行由调度器安排调度,调度器是与
- 操作系统紧密相关的,先后顺序是不能认为的干预的。
- 对同一份资源操作时,会存在资源抢夺的问题,需要加入并发控制;
- 线程会带来额外的开销,如cpu调度时间,并发控制开销。
- 每个线程在自己的工作内存交互,内存控制不当会造成数据不一致