多线程基础体系知识清单
前言
本文会介绍Java中多线程与并发的基础,适合初学者食用。
线程与进程的区别
在计算机发展初期,每台计算机是串行地执行任务的,如果碰上需要IO的地方,还需要等待长时间的用户IO,后来经过一段时间有了批处理计算机,其可以批量串行地处理用户指令,但本质还是串行,还是不能并发执行。
如何解决并发执行的问题呢?于是引入了进程的概念,每个进程独占一份内存空间,进程是内存分配的最小单位,相互间运行互不干扰且可以相互切换,现在我们所看到的多个进程“同时"在运行,实际上是进程高速切换的效果。
那么有了线程之后,我们的计算机系统看似已经很完美了,为什么还要进入线程呢?如果一个进程有多个子任务,往往一个进程需要逐个去执行这些子任务,但往往这些子任务是不相互依赖的,可以并发执行,所以需要CPU进行更细粒度的切换。所以就引入了线程的概念,线程隶属于某一个进程,它共享进程的内存资源,相互间切换更快速。
进程与线程的区别:
进程是资源分配的最小单位,线程是CPU调度的最小单位。所有与进程相关的资源,均被记录在PCB中。
线程隶属于某一个进程,共享所属进程的资源。线程只由堆栈寄存器、程序计数器和TCB构成。
进程可以看作独立的应用,线程不能看作独立的应用。
进程有独立的地址空间,相互不影响,而线程只是进程的不同执行路径,如果线程挂了,进程也就挂了。所以多进程的程序比多线程程序健壮,但是切换消耗资源多。
Java中进程与线程的关系:
运行一个程序会产生一个进程,进程至少包含一个线程。
每个进程对应一个JVM实例,多个线程共享JVM中的堆。
Java采用单线程编程模型,程序会自动创建主线程 。
主线程可以创建子线程,原则上要后于子线程完成执行。
线程的start方法和run方法的区别
区别
Java中创建线程的方式有两种,不管使用继承Thread的方式还是实现Runnable接口的方式,都需要重写run方法。调用start方法会创建一个新的线程并启动,run方法只是启动线程后的回调函数,如果调用run方法,那么执行run方法的线程不会是新创建的线程,而如果使用start方法,那么执行run方法的线程就是我们刚刚启动的那个线程。
程序验证
public class Main {public static void main(String[] args) {Thread thread = new Thread(new SubThread());thread.run();thread.start();}}
class SubThread implements Runnable{@Overridepublic void run() {// TODO Auto-generated method stubSystem.out.println("执行本方法的线程:"+Thread.currentThread().getName());}}
Thread和Runnable的关系
Thread源码
Runnable源码
区别
通过上述源码图,不难看出,Thread是一个类,而Runnable是一个接口,Runnable接口中只有一个没有实现的run方法,可以得知,Runnable并不能独立开启一个线程,而是依赖Thread类去创建线程,执行自己的run方法,去执行相应的业务逻辑,才能让这个类具备多线程的特性。
使用继承Thread方式和实现Runable接口方式分别创建子线程
使用继承Thread类方式创建子线程
public class Main extends Thread{public static void main(String[] args) {Main main = new Main();main.start();}@Overridepublic void run() {System.out.println("通过继承Thread接口方式创建子线程成功,当前线程名:"+Thread.currentThread().getName());}}
运行结果:
使用实现Runnable接口方式创建子线程
public class Main{public static void main(String[] args) {SubThread subThread = new SubThread();Thread thread = new Thread(subThread);thread.start();}}
class SubThread implements Runnable{@Overridepublic void run() {// TODO Auto-generated method stubSystem.out.println("通过实现Runnable接口创建子线程成功,当前线程名:"+Thread.currentThread().getName());}}
运行结果:
使用匿名内部类方式创建子线程
public class Main{public static void main(String[] args) {Thread thread = new Thread(new Runnable() {@Overridepublic void run() {// TODO Auto-generated method stubSystem.out.println("使用匿名内部类方式创建线程成功,当前线程名:"+Thread.currentThread().getName());}});thread.start();}
}
运行结果:
关系
Thread是实现了Runnable接口的类,使得run支持多线程。
因类的单一继承原则,推荐使用Runnable接口,可以使程序更加灵活。
如何实现处理多线程的返回值
通过刚才的学习,我们知道多线程的逻辑需要放到run方法中去执行,而run方法是没有返回值的,那么遇到需要返回值的状况就不好解决,那么如何实现子线程返回值呢?
主线程等待法
通过让主线程等待,直到子线程运行完毕为止。
实现方式:
public class Main{static String str;public static void main(String[] args) {Thread thread = new Thread(new Runnable() {@Overridepublic void run() {str="子线程执行完毕";}});thread.start();//如果子线程还未对str进行赋值,则一直轮转while(str==null) {}System.out.println(str);}
}
使用Thread中的join()方法
join()方法可以阻塞当前线程以等待子线程处理完毕。
实现方式:
public class Main{static String str;public static void main(String[] args) {Thread thread = new Thread(new Runnable() {@Overridepublic void run() {str="子线程执行完毕";}});thread.start();//如果子线程还未对str进行赋值,则一直轮转try {thread.join();} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println(str);}
}
join方法能做到比主线程等待法更精准的控制,但是join方法的控制粒度并不够细。比如,我需要控制子线程将字符串赋一个特定的值时,再执行主线程,这种操作join方法是没有办法做到的。
通过Callable接口实现:通过FutureTask或者线程池获取
在JDK1.5之前,线程是没有返回值的,通常程序猿需要获取子线程返回值颇费周折,现在Java有了自己的返回值线程,即实现了Callable接口的线程,执行了实现Callable接口的线程之后,可以获得一个Future对象,在该对象上调用一个get方法,就可以执行子线程的逻辑并获取返回的Object。
实现方式1(直接获取):
public class Main implements Callable<String>{@Overridepublic String call() throws Exception {// TODO Auto-generated method stubString str = "我是带返回值的子线程";return str;}public static void main(String[] args) {Main main = new Main();try {String str = main.call();System.out.println(str);} catch (Exception e) {// TODO Auto-generated catch blocke.printStackTrace();}}
}
运行结果:
实现方式2(使用FutureTask):
public class Main implements Callable<String>{@Overridepublic String call() throws Exception {// TODO Auto-generated method stubString str = "我是带返回值的子线程";return str;}public static void main(String[] args) {FutureTask<String> task = new FutureTask<String>(new Main());new Thread(task).start();try {if(!task.isDone()) {System.out.println("任务没有执行完成");}System.out.println("等待中...");Thread.sleep(3000);System.out.println(task.get());} catch (InterruptedException | ExecutionException e) {// TODO Auto-generated catch blocke.printStackTrace();}}
}
运行结果:
实现方法3(使用线程池配合Future获取):
public class Main implements Callable<String>{@Overridepublic String call() throws Exception {// TODO Auto-generated method stubString str = "我是带返回值的子线程";return str;}public static void main(String[] args) throws InterruptedException, ExecutionException {ExecutorService newCacheThreadPool = Executors.newCachedThreadPool(); Future<String> future = newCacheThreadPool.submit(new Main());if(!future.isDone()) {System.out.println("线程尚未执行结束");}System.out.println("等待中");Thread.sleep(300);System.out.println(future.get());newCacheThreadPool.shutdown();}
}
运行结果:
线程的状态
Java线程主要分为以下六个状态:新建态(new),运行态(Runnable),无限期等待(Waiting),限期等待(TimeWaiting),阻塞态(Blocked),结束(Terminated)。
新建(new)
新建态是线程处于已被创建但没有被启动的状态,在该状态下的线程只是被创建出来了,但并没有开始执行其内部逻辑。
运行(Runnable)
运行态分为Ready和Running,当线程调用start方法后,并不会立即执行,而是去争夺CPU,当线程没有开始执行时,其状态就是Ready,而当线程获取CPU时间片后,从Ready态转为Running态。
等待(Waiting)
处于等待状态的线程不会自动苏醒,而只有等待被其它线程唤醒,在等待状态中该线程不会被CPU分配时间,将一直被阻塞。以下操作会造成线程的等待:
没有设置timeout参数的Object.wait()方法。
没有设置timeout参数的Thread.join()方法。
LockSupport.park()方法(实际上park方法并不是LockSupport提供的,而是在Unsafe中,LockSupport只是对其做了一层封装,可以看我的另一篇博客《锁》,里面对于ReentrantLock的源码解析有提到这个方法)。
锁:https://juejin.im/post/5d8da403f265da5b5d203bf4
限期等待(TimeWaiting)
处于限期等待的线程,CPU同样不会分配时间片,但存在于限期等待的线程无需被其它线程显式唤醒,而是在等待时间结束后,系统自动唤醒。以下操作会造成线程限时等待:
Thread.sleep()方法。
设置了timeout参数的Object.wait()方法。
设置了timeout参数的Thread.join()方法。
LockSupport.parkNanos()方法。
LockSupport.parkUntil()方法。
阻塞(Blocked)
当多个线程进入同一块共享区域时,例如Synchronized块、ReentrantLock控制的区域等,会去整夺锁,成功获取锁的线程继续往下执行,而没有获取锁的线程将进入阻塞状态,等待获取锁。
结束(Terminated)
已终止线程的线程状态,线程已结束执行。
Sleep和Wait的区别
Sleep和Wait者两个方法都可以使线程进入限期等待的状态,那么这两个方法有什么区别呢?
sleep方法由Thread提供,而wait方法由Object提供。
sleep方法可以在任何地方使用,而wait方法只能在synchronized块或synchronized方法中使用(因为必须获wait方法会释放锁,只有获取锁了才能释放锁)。
sleep方法只会让出CPU,不会释放锁,而wait方法不仅会让出CPU,还会释放锁。
测试代码:
public class Main{public static void main(String[] args) {Thread threadA = new Thread(new ThreadA());Thread threadB = new Thread(new ThreadB());threadA.setName("threadA");threadB.setName("threadB");threadA.start();threadB.start();}public static synchronized void print() {System.out.println("当前线程:"+Thread.currentThread().getName()+"执行Sleep");try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("当前线程:"+Thread.currentThread().getName()+"执行Wait");try {Main.class.wait(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");}
}
class ThreadA implements Runnable{@Overridepublic void run() {// TODO Auto-generated method stubMain.print();}}
class ThreadB implements Runnable{@Overridepublic void run() {// TODO Auto-generated method stubMain.print();}}
执行结果:
从上面的结果可以分析出:当线程A执行sleep后,等待一秒被唤醒后继续持有锁,执行之后的代码,而执行wait之后,立即释放了锁,不仅让出了CPU还让出了锁,而后线程B立即持有锁开始执行,和线程A执行了同样的步骤,当线程B执行wait方法之后,释放锁,然后线程A拿到锁打印了第一个执行完毕,然后线程B打印执行完毕。
notify和notifyAll的区别
notify
notify可以唤醒一个处于等待状态的线程,上代码:
public class Main{public static void main(String[] args) {Object lock = new Object();Thread threadA = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock) {try {lock.wait();} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}print();}}});Thread threadB = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock) {print();lock.notify();}}});threadA.setName("threadA");threadB.setName("threadB");threadA.start();threadB.start();}public static void print() {System.out.println("当前线程:"+Thread.currentThread().getName()+"执行print");try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");}
}
执行结果:
代码解释:线程A在开始执行时立即调用wait进入无限等待状态,如果没有别的线程来唤醒它,它将一直等待下去,所以此时B持有锁开始执行,并且在执行完毕时调用了notify方法,该方法可以唤醒wait状态的A线程,于是A线程苏醒,开始执行剩下的代码。
notifyAll
notifyAll可以用于唤醒所有等待的线程,使所有处于等待状态的线程都变为ready状态,去重新争夺锁。
public class Main{public static void main(String[] args) {Object lock = new Object();Thread threadA = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock) {try {lock.wait();} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}print();}}});Thread threadB = new Thread(new Runnable() {@Overridepublic void run() {synchronized (lock) {print();lock.notifyAll();}}});threadA.setName("threadA");threadB.setName("threadB");threadA.start();threadB.start();}public static void print() {System.out.println("当前线程:"+Thread.currentThread().getName()+"执行print");try {Thread.sleep(1000);} catch (InterruptedException e) {// TODO Auto-generated catch blocke.printStackTrace();}System.out.println("当前线程:"+Thread.currentThread().getName()+"执行完毕");}
}
执行结果:
要唤醒前一个例子中的线程A,不光notify方法可以做到,调用notifyAll方法同样也可以做到,那么两者有什么区别呢?
区别
要说清楚他们的区别,首先要简单的说一下Java synchronized的一些原理,在openjdk中查看java的源码可以看到,java对象中存在monitor锁,monitor对象中包含锁池和等待池。
锁池,假设有多个对象进入synchronized块争夺锁,而此时已经有一个对象获取到了锁,那么剩余争夺锁的对象将直接进入锁池中。
等待池,假设某个线程调用了对象的wait方法,那么这个线程将直接进入等待池,而等待池中的对象不会去争夺锁,而是等待被唤醒。
下面可以说notify和notifyAll的区别了:
notifyAll会让所有处于等待池中的线程全部进入锁池去争夺锁,而notify只会随机让其中一个线程去争夺锁。
yield方法
概念
/*** A hint to the scheduler that the current thread is willing to yield* its current use of a processor. The scheduler is free to ignore this* hint.** <p> Yield is a heuristic attempt to improve relative progression* between threads that would otherwise over-utilise a CPU. Its use* should be combined with detailed profiling and benchmarking to* ensure that it actually has the desired effect.** <p> It is rarely appropriate to use this method. It may be useful* for debugging or testing purposes, where it may help to reproduce* bugs due to race conditions. It may also be useful when designing* concurrency control constructs such as the ones in the* {@link java.util.concurrent.locks} package.*/public static native void yield();
yield源码上有一段长长的注释,其大意是说:当前线程调用yield方法时,会给当前线程调度器一个暗示,当前线程愿意让出CPU的使用,但是它的作用应结合详细的分析和测试来确保已经达到了预期的效果,因为调度器可能会无视这个暗示,使用这个方法是不那么合适的,或许在测试环境中使用它会比较好。
测试:
public class Main{public static void main(String[] args) {Thread threadA = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("ThreadA正在执行yield");Thread.yield();System.out.println("ThreadA执行yield方法完成");}});Thread threadB = new Thread(new Runnable() {@Overridepublic void run() {System.out.println("ThreadB正在执行yield");Thread.yield();System.out.println("ThreadB执行yield方法完成");}});threadA.setName("threadA");threadB.setName("threadB");threadA.start();threadB.start();}
测试结果:
可以看出,存在不同的测试结果,这里选出两张。
第一种结果:线程A执行完yield方法,让出cpu给线程B执行。然后两个线程继续执行剩下的代码。
第二种结果:线程A执行yield方法,让出cpu给线程B执行,但是线程B执行yield方法后并没有让出cpu,而是继续往下执行,此时就是系统无视了这个暗示。
interrupt方法
中止线程
interrupt函数可以中断一个线程,在interrupt之前,通常使用stop方法来终止一个线程,但是stop方法过于暴力,它的特点是,不论被中断的线程之前处于一个什么样的状态,都无条件中断,这会导致被中断的线程后续的一些清理工作无法顺利完成,引发一些不必要的异常和隐患,还有可能引发数据不同步的问题。
温柔的interrupt方法
interrupt方法的原理与stop方法相比就显得温柔的多,当调用interrupt方法去终止一个线程时,它并不会暴力地强制终止线程,而是通知这个线程应该要被中断了,和yield一样,这也是一种暗示,至于是否应该中断,由被中断的线程自己去决定。当对一个线程调用interrupt方法时:
如果该线程处于被阻塞状态,则立即退出阻塞状态,抛出InterruptedException异常。
如果该线程处于running状态,则将该线程的中断标志位设置为true,被设置的线程继续运行,不受影响,当运行结束时由线程决定是否被中断。
线程池
线程池的引入是用来解决在日常开发的多线程开发中,如果开发者需要使用到非常多的线程,那么这些线程在被频繁的创建和销毁时,会对系统造成一定的影响,有可能系统在创建和销毁这些线程所耗费的时间会比完成实际需求的时间还要长。
另外,在线程很多的状况下,对线程的管理就形成了一个很大的问题,开发者通常要将注意力从功能上转移到对杂乱无章的线程进行管理上,这项动作实际上是非常耗费精力的。
利用Executors创建不同的线程池满足不同场景的需求
newFixThreadPool(int nThreads)
指定工作线程数量的线程池。
newCachedThreadPool()
处理大量中断事件工作任务的线程池,
试图缓存线程并重用,当无缓存线程可用时,就会创建新的工作线程。
如果线程闲置的时间超过阈值,则会被终止并移出缓存。
系统长时间闲置的时候,不会消耗什么资源。
newSingleThreadExecutor()
创建唯一的工作线程来执行任务,如果线程异常结束,会有另一个线程取代它。可保证顺序执行任务。
newSingleThreadScheduledExecutor()与newScheduledThreadPool(int corePoolSize)
定时或周期性工作调度,两者的区别在于前者是单一工作线程,后者是多线程
newWorkStealingPool()
内部构建ForkJoinPool,利用working-stealing算法,并行地处理任务,不保证处理顺序。
Fork/Join框架:把大任务分割称若干个小任务并行执行,最终汇总每个小任务后得到大任务结果的框架。
为什么要使用线程池
线程是稀缺资源,如果无限制地创建线程,会消耗系统资源,而线程池可以代替开发者管理线程,一个线程在结束运行后,不会销毁线程,而是将线程归还线程池,由线程池再进行管理,这样就可以对线程进行复用。
所以线程池不但可以降低资源的消耗,还可以提高线程的可管理性。
使用线程池启动线程
public class Main{public static void main(String[] args) {ExecutorService newFixThreadPool = Executors.newFixedThreadPool(10);newFixThreadPool.execute(new Runnable() {@Overridepublic void run() {// TODO Auto-generated method stubSystem.out.println("通过线程池启动线程成功");}});newFixThreadPool.shutdown();}
}
新任务execute执行后的判断
要知道这个点首先要先说说ThreadPoolExecutor的构造函数,其中有几个参数:
corePoolSize:核心线程数量。
maximumPoolSize:线程不够用时能创建的最大线程数。
workQueue:等待队列。
那么新任务提交后会执行下列判断:
如果运行的线程少于corePoolSize,则创建新线程来处理任务,即时线程池中的其它线程是空闲的。
如果线程池中的数量大于等于corePoolSize且小于maximumPoolSize,则只有当workQueue满时,才创建新的线程去处理任务。
如果设置的corePoolSize和maximumPoolSize相同,则创建的线程池大小是固定的,如果此时有新任务提交,若workQueue未满,则放入workQueue,等待被处理。
如果运行的线程数大于等于maximumPoolSize,maximumPoolSize,这时如果workQueue已经满了,则通过handler所指定的策略来处理任务。
handler 线程池饱和策略
AbortPolicy:直接抛出异常,默认。
CallerRunsPolicy:用调用者所在的线程来执行任务。
DiscardOldestPolicy:丢弃队列中靠最前的任务,并执行当前任务。
DiscardPolicy:直接丢弃任务
自定义。
线程池的大小如何选定
这个问题并不是什么秘密,在网上各大技术网站均有文章说明,我就拿一个最受认可的写上吧
CPU密集型:线程数 = 核心数或者核心数+1
IO密集型:线程数 = CPU核数*(1+平均等待时间/平均工作时间)
当然这个也不能完全依赖这个公式,更多的是要依赖平时的经验来操作,这个公式也只是仅供参考而已。
结语
本文提供了一些Java多线程和并发方面最最基础的知识,适合初学者了解Java多线程的一些基本知识,如果想了解更多的关于并发方面的内容可以看:
https://juejin.im/post/5d8da403f265da5b5d203bf4
1. SpringBoot内容聚合
2. 面试题内容聚合
3. 设计模式内容聚合
4. Mybatis内容聚合
5. 多线程内容聚合
最后,推荐一个专注于Java学习的公众号,Java知音。分享java基础、原理性知识、JavaWeb实战、spring全家桶、设计模式及面试资料、开源项目,助力开发者成长!
多线程基础体系知识清单相关推荐
- 主线程如何等待多线程完成 返回数据_多线程基础体系知识清单
作者:Object 来源:https://juejin.im/user/5d53e1f6f265da03af19cae0/posts 前言 本文会介绍Java中多线程与并发的基础,适合初学者食用. 线 ...
- 入门Python,看这一篇就够了,史上最全的Python基础语法知识清单!
Python崇尚优美.清晰.简单,是一个优秀并广泛使用的脚本语言.Python可以应用于众多领域,如:数据分析.组件集成.网络服务.图像处理.数值计算和科学计算等众多领域. 目前业内几乎所有大中型互联 ...
- Juc00_多线程基础小知识
文章目录 ①. Thread.activeCount( ) ②. TimeUnit类 ①. Thread.activeCount( ) ①. 在IDEA中Thread.activeCount()=2, ...
- Java多线程基础知识
多线程基础知识 这是我再次学习多线程知识的一个总结,对于刚刚接触的学习者是比较友好易懂的,便于快速的理解和掌握. 一.基本概念: 1.进程:进程就是运行中的程序,当一个程序开始执行,操作系统就会给这个 ...
- 并发编程(一)多线程基础和原理
多线程基础 最近,准备回顾下多线程相关的知识体系,顺便在这里做个记录. 并发的发展历史 最早的计算机只能解决简单的数学运算问题,比如正弦. 余弦等.运行方式:程序员首先把程序写到纸上,然后穿 孔成卡片 ...
- 并发编程-多线程基础
1.引言 推荐书籍 深入理解Java并发编程 Java并发编程 核心知识点 多线程基础知识 同步和异步的概念 线程安全(线程同步)相关 线程通讯 java1.8并发包 线程池原理分析 锁的概念 专题类 ...
- 企业质量管理的25个知识清单
什么是质量管理?华天谋精益质量管理专家概述:质量管理体系(Quality Management System,QMS)是指在质量方面指挥和控制组织的管理体系.质量管理体系是组织内部建立的.为实现质量目 ...
- python基础语法及知识总结-Python 学习完基础语法知识后,如何进一步提高?
---4.30更新---感谢大家支持,点赞都破两千了.给大家整理出来新的资料,需要的小伙伴来自取: Python练手项目视频如下: Python自学基础知识如下: 以下为原文内容: Python 学习 ...
- 给你一份长长长的 Spring Boot 知识清单(上)
预警:本文非常长,建议先mark后看,也许是最后一次写这么长的文章 说明:前面有4个小节关于Spring的基础知识,分别是:IOC容器.JavaConfig.事件监听.SpringFactoriesL ...
最新文章
- 阿里云飞天技术出海 eWTP落地马来西亚
- 简易嵌入式管理平台 C 实现
- gcc编译c文件_Linux下C语言程序的编译过程
- 12 HTML5中的影音播放
- 屏蔽firefox浏览器连接失败页面的广告
- CentOS服务器上的 git 包版本控制
- java学习笔记8--接口总结
- FastReport的模板文件frx文件啊怎样打开并编辑修改
- 送给即将毕业的大学生:乔布斯在斯坦福的大学演讲
- java表达式由什么组成_必知必会之Lambda表达式
- CodeForces Round #290 Div.2
- Android 学习JNI,用JAVA调用C
- 给table表格加斜线
- 布同:如何循序渐进学习Python语言
- Unity Timeline自定义轨道
- 证件照分辨率350dpi怎么调?怎么调照片dpi?
- 学 Python 和学 Java ,哪个好找工作?
- java 中的NIO
- PowerShell隐藏不显示窗口的多种方法
- 闭关修炼21天终于拿到offer
热门文章
- 放回、不放回的概率计算
- Java代码覆盖率框架JaCoCo的core-instr core.internal.instr 包类源码解析
- Centos6.5系统安装教程
- 奋斗吧,程序员——第十三章 蓦然回首,那人却在灯火阑珊处
- 大数据精准营销:如何找对人做对事?
- 七周二次课(5月7日)
- 【Android】如何实现启动APP时引导页、欢迎页功能设置之(二)设置只在第一次启动APP时跳入引导界面
- MessageBox.Show 参数详解
- 完美进化.量化管理管理出效益.王磊老师量化管理2
- RS推荐系统-基于流行度的推荐