常见的集合框架
引用数据类型一旦超过了它所表示的范围就会new一个新的对象
public static void main(String[] args) {
Integer a = 100;
Integer b = 100;
System.out.println(a == b);//[-128,127]
Integer a1 = 200;
Integer b1 = 200;//源码里面,如果超过127则会new一个新的对象
System.out.println(a1 == b1);
}
//
true
false
泛型就是适用于很多类型,从代码来说就是对类型实现了参数化
关于擦除机制: 编译的时候所有的T都被替换成Object,
顺序表和链表/ArrayList和LinkedList/数组和链表的区别
ArrayList需要注意的点
1. 我们在创建ArrayList实例的时候,不设置参数,我们调用的是无参构造方法,里面是一个空的数组,没有分配内存.
2. 当第一次add的时候,我们才会分配大小为10的内存
3. 如果add方法检测出size大小等于数组大小,就会进行1.5倍的扩容
ArrayList是基于动态数据实现的,它的逻辑地址和物理地址都是连续的.
优点:
1> ArrayList 是实现了基于动态数组的数据结构,因为地址连续,一旦数据存储好了,查询
操作效率会比较高(在内存里是连着放的)。查询的时间复杂度是O(1)
缺点:
1> 插入数据必须移动其他数据,最坏的情况下插入到第一个位置,时间复杂度是O(N)
2> 删除数据也必须移动其他数据,最坏情况下删除第一个位置,时间复杂度是O(N)
3> 1.5倍扩容之后,假设我们只用了扩容后的一点,会造成空间的浪费
总结: 顺序表比较适合进行给指定下标查找的情况
LinkedList
LinkedList(双向链表) 基于链表的数据结构,链表是由一个个节点通过引用链接组成,每个结点分为value数据域和next引用域组成.
优点:LinkedList 基于链表的数据结构,地址是任意的,所以在开辟内存空间的时候不需要等一个连续的地址。对于新增和删除操作,LinkedList 比较占优势。LinkedList 适用于要头尾操作或插入指定位置的场景。
缺点:因为 LinkedList 要移动指针,所以查询操作性能比较低。
适用场景:
1> 如果需要根据下标来查询元素就使用顺序表
2> 如果经常进行元素的插入和删除操作,适合使用链表
3> 如果容量固定,并且只会添加到尾部,不会引起扩容,优先采用 ArrayList。
不同点 | ArrayList | LinkedList |
存储空间上 | 物理上一定连续 | 逻辑上连续,但物理上不一定连续 |
随机访问 | 支持O(1) | 不支持:O(N) |
头插 | 需要搬移元素,效率低O(N) | 只需修改引用的指向,时间复杂度为O(1) |
插入 | 空间不够时需要扩容 | 没有容量的概念 |
应用场景 | 元素高效存储+频繁访问 | 任意位置插入和删除频繁 |
用过 ArrayList 吗?说一下它有什么特点?
1> ArarayList是基于动态数组实现的,当增加到一定程度的时候会自动1.5倍扩容
2> 我们在创建ArrayList实例的时候,不设置参数,我们调用的是无参构造方法,里面是一个空的数组,没有分配内存.
3> 当第一次add的时候,我们才会分配大小为10的内存
4> 高并发的情况下,线程不安全。多个线程同时操作 ArrayList,会引发不可预知的异常或错误。
5> 实现了ReandomAccess接口,可以支持随机访问
6>实现 了Cloneable接口,表面是可以克隆的
有数组了为什么还要搞个 ArrayList 呢?
如果在不明确插入数据数量的情况下,普通数组就难以实现的,因为你不晓得初始化数组大小为多少,而ArrayList可以使用默认的大小,当达到一定程度后,会自动扩容.
栈和队列
栈: 后进先出的数据结构
栈、虚拟机栈、栈帧有什么区别呢?
虚拟机栈: 是Java虚拟机(JVM)的一个概念,每个线程运行的时候都会创建自己的虚拟机栈.
简而言之: JVM划分的一块内存.
栈帧: 是用于支持Java虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的一部分,每次发生方法的调用就会为该方法创建一个新的栈帧并压入栈顶.
简而言之: 方法调用的时候会在虚拟机给方法开辟一块内存.
栈: 是一种规定只能在一端进行插入或者删除的线性表,是一种后进先出的数据结构.
队列: 先进先出的线性表
栈和队列都可以使用数组和链表进行实现
二叉树
一棵二叉树是结点的一个有限集合,该集合:
1. 或者为空
2. 或者是由一个根节点加上两棵别称为左子树和右子树的二叉树组成。
NLR:前序遍历(Preorder Traversal 亦称先序遍历)——访问根结点--->根的左子树--->根的右子树。
LNR:中序遍历(Inorder Traversal)——根的左子树--->根节点--->根的右子树。
LRN:后序遍历(Postorder Traversal)——根的左子树--->根的右子树--->根节点。
优先级队列
队列是一种先进先出的数据结构,而如果我们操作的数据带有优先级,我们出队的时候就会先出队优先级最高的元素.是由堆来进行实现的.
堆中某个节点的值总是不大于或不小于其父节点的值;
堆分为大根堆和小根堆
1> 小根堆
根结点一定小于孩子结点->小根堆
2> 大根堆
根结点一定大于孩子结点->大根堆
完全二叉树
优先级队列的扩容机制
如果容量小于64时,是按照oldCapacity的2倍方式扩容的
如果容量大于等于64,是按照oldCapacity的1.5倍方式扩容的
如果容量超过MAX_ARRAY_SIZE,按照MAX_ARRAY_SIZE来进行扩容
PriorityQueue默认情况下是小堆---即每次获取到的元素都是最小的元素
排序算法
插入排序
1. 直接插入排序
思想:
- 从第二个元素开始,假设第一个元素已经是排好序的。
- 将当前元素与前面的元素进行比较,如果比它小就往前进行覆盖,然后将当前元素插入到适当的位置,使得插入后的部分依然是有序的。
- 重复这个过程,直到整个数组有序。
使用i往前走,j用来遍历有序区间
代码:
public static void insertSort(int[] array) {
//从第一个元素开始
for (int i = 1; i < array.length ; i++) {
int j = i - 1;
//保存当前下标的值
int tem = array[i];
//加不加=会对稳定性产生影响,如果一个排序是稳定的就可以变成不稳定的,但是不稳定的不能变成稳定的)
for (; j >= 0 ; j--) {//j所在的区间是有序区间
//如果前面的元素大于tem,就往前移动
if(array[j] > tem) {
array[j+1] = array[j];//不用i的原因是j会一直改变,不一定就是j的前一个
}else {
break;
}
//最后把元素放在前面
//因为循环出来的条件表示必须j要小于0,所以直接传j会下标越界
}
array[j + 1] = tem;
}
}
时间复杂度:O(n^2)
稳定性: 稳定
2. 希尔排序
思想:
1. 分组,组内进行排序(直接插入排序)
2. 然后逐渐缩小组数
定义一个gap,然后每一次按照gap来进行一次直接插入排序(越有序越快),再减小gap的大小
代码:
public static void shellsort(int[] array) {
//分组
int gap = array.length;
while (gap > 0){
gap /= 2;
//每次分组进行一次直接插入排序
shell(array,gap);
}
}
public static void shell(int[] array,int gap) {
//我们默认第一个元素是有序的
for (int i = gap; i < array.length; i++) {
int temp = array[i];
//j下标等于i前面一个gap的下标值
int j = i - gap;
//然后逐渐和该组前面的数进行比较
for(; j >= 0 ; j -= gap) {
if(array[j] > temp) {
//往前移动一个gap的
array[j + gap] = array[j];
}else {
break;
}
}
array[j + gap] = temp;
}
}
时间复杂度: O(n^0.25)~O(1.6n^0.25)
稳定性: 不稳定
选择排序
3. 选择排序
思想:
1. 俩层循环, 外层循环相当于每一次提供一个比较数,内层循环就把比较数后面的数和比较数进行比较,如果比比较数还小,就记录下来下标的值
2. 内层循环完之后,说明我们找到了这一趟最小值的下标,让其和外层循环所指的元素进行交换.
1> 我们定义i,j
2> 记录i下标为初始的min值下标
3> 然后我们让j遍历i后面的元素,和min下标的值做比较,然后返回这一趟找到的最小值的下标
4> 交换i和min下标的元素,每一趟都能找到这一趟最小的元素,然后放到前面去
代码:
public static void selectSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int min = i;
//让j为i+1
for (int j = i + 1; j < array.length; j++) {
if(array[min] > array[j]) {
min = j;//min保存的是每一趟最小值的下标
}
}
int tmp = array[i];
array[i] = array[min];
array[min] = tmp;
}
}
时间复杂度:O(n^2)
稳定性: 不稳定
4. 堆排序
思想: 升序排序,每一次建立大根堆,然后第一个元素和最后一个元素进行交换,然后对除了换过来的以外元素进行向下调整建立大根堆,这样每次找到的都是该趟的最大值.相反如果进行降序排序,利用的是小根堆.
1. 创建大根堆,把所有元素放在大根堆里面
2. 定义变量end,作为最后一个有效元素的下标
3. 交换0下标和end下标的值
4. 进行0下标到end下标的向下调整
5. end--;
代码:
//堆排序
private static void creatHeap(int[] array) {
for (int parent = (array.length - 1 - 1) / 2; parent >= 0 ; parent--) {
siftDown(array,parent,array.length);
}
}
//TODO alt+enter
private static void siftDown(int[] array,int parent,int length) {
int child = 2 * parent + 1;
while (child < length) {
if(child + 1 < length && array[child] < array[child + 1]) {
child++;
}
if(array[child] > array[parent]) {
swap(array,child,parent);
parent = child;
child = 2*parent+1;
}else {
break;
}
}
}
public static void heapSort(int[] array) {
//创建大根堆
creatHeap(array);
int end = array.length - 1;
while (end > 0) {
swap(array,0,end);
siftDown(array,0,end);
end--;
}
}
时间复杂度: O(n * log(n))
稳定性: 不稳定
交换排序
5. 冒泡排序
思想:
内层循环每次每次从0开始,把小的交换到前面,大的换到后面,每一轮回,把该轮的最大值换到最后.
代码:
public static void bubbleSort(int[] array) {
//i表示趟数
for (int i = 0; i < array.length - 1; i++) {
//j来比较每个数据的大小
for (int j = 0; j < array.length - 1 ; j++) {
if(array[j] > array[j + 1]) {
swap(array,j,j+1);
}
}
}
}
优化:
1. 减少j的遍历次数
2. 考虑极端情况,如果有序的话,我们就直接可以结束循环
public static void bubbleSort1(int[] array) {
//i表示趟数
for (int i = 0; i < array.length - 1; i++) {
boolean flag = false;
//j来比较每个数据的大小
// for (int j = 0; j < array.length - 1 - i; j++) {
for (int j = 0; j < array.length - 1 - i ; j++) {//优化1
if(array[j] > array[j + 1]) {
swap(array,j,j+1);
flag = true;
}
}
if (flag == false) {
break;
}
}
}
时间复杂度:O(n^2)
稳定性: 不稳定
6. 快速排序
思想:
hoare法
首先我们把第一个元素作为基准值,让left和right分别指向数组的开头和结尾,然后我们让left去寻找比基准值大的数,让right去寻找比基准值小的数,然后我们把找到的下标丢到swap里面进行交换,然后继续上面的操作直到left和right相遇为止.然后我们更新基准值,把相遇位置和之前的基准位置进行交换,这样我们就以基准值作为标准,左边都是比基准值小的,右边都是比基准大的,然后我们再通过递归,在左右区间重复刚刚的操作.
代码:
public static void quickSort(int[] array) {
quick(array,0,array.length - 1);
}
private static void quick(int[] array, int start, int end) {
//走到头了
if (start >= end) {
return;
}
//找到基准值
//相当于二叉树的前序遍历
int pivot = partitionHoare(array,start,end);
//调整左区间
quick(array,start,pivot - 1);
//调整右区间
quick(array,pivot + 1,end);
}
private static int partitionHoare(int[] array, int left, int right) {
//记录第一个元素的值,把它作为基准
int tmp = array[left];
int i = left;
while (left < right) {
while (left < right && array[right] >= tmp) {
right--;
}
while (left < right && array[left] <= tmp) {
left++;
}
//此时左边找到了比基准大的值,右边找到了比基准小的值,那么我们就进行交换
swap(array,right,left);
}
//然后我们把相遇的值和基准的值进行交换
swap(array,i,left);
//返回基准值的下标
return left;
}
其他方法的思想:
挖坑法:
先让最左边作为基准值,定义left和right分别指向数组的开头和结尾,然后right一直向后找,去找到比基准值小的数,然后和覆盖left位置的数,同理left一直向前找找到比基准值大的数,然后覆盖right位置的值.当left和right相遇的时候,我们把原先的基准值填空到这个位置,这个位置作为新的基准值,进行下一轮递归.
时间复杂度: O(n^2)
稳定性: 不稳定
归并排序
7. 归并排序
思想: 分治思想,先分解再合并
1> 分成一个个子序列,让子序列有序
2> 然后再进行合并,合并后任然保证有序
即先使每个子序列有序,再使子序列段间有序。
分成小区间,先让小区间有序,然后在进行合并的时候,通过比较然后让合并的大区间也有序
代码:
private static void merge(int[] array, int left,int mid, int right) {
int s1 = left;
int e1 = mid;
int s2 = mid+1;
int e2 = right;
int i = 0;//新数组的下标
//创建一个新的数组
int[] arryTemp = new int[right - left + 1];
//俩端都有元素的时候
while (s1 <= e1 && s2 <= e2) {
//进行比较
if(array[s1] <= array[s2]) {
arryTemp[i++] = array[s1++];
}else {
arryTemp[i++] = array[s2++];
}
}
//此时只有一端有元素,就直接放进数组即可
while (s1 <= e1) {
arryTemp[i++] = array[s1++];
}
while (s2 <= e2) {
arryTemp[i++] = array[s2++];
}
//我们要把元素放进原来的数组里面
for (int j = 0; j < arryTemp.length; j++) {
array[j + left] = arryTemp[j];
}
}
//非递归
private static void mergeSortNor(int[] array) {
//每组多少个数据
int gap = 1;
while (gap <= array.length) {
for (int i = 0; i < array.length; i = i + 2 * gap) {//i每一次循环都在gap个元素的分组下进行排序
//确定左右分组的边界值
int left = i;
int mid = left + gap - 1;
int right = mid + gap;
//防止越界操作,比如left是数组的最后一个元素,我们Mid和right就越界了
if(mid >= array.length) {
mid = array.length - 1;
}
if(right >= array.length) {
right = array.length - 1;
}
//合并左右半区的元素
merge(array,left,mid,right);
}
//增大每组的元素
gap *=2;
}
}
时间复杂度: O(nlgn)
稳定性: 稳定
排序方法 | 最好 | 平均 | 最坏 | 空间复杂度 | 稳定性 |
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
希尔排序 | O(n) | O(n^1.3) | O(n^0.15) | O(1) | 不稳定 |
堆排序 | O(n * log(n)) | O(n * log(n)) | O(n * log(n)) | O(1) | 不稳定 |
快速排序 | O(n * log(n)) | O(n * log(n)) | O(n^2) | O(log(n)) ~ O(n) | 不稳定 |
归并排序 | O(n * log(n)) | O(n * log(n)) | O(n * log(n)) | O(n) | 稳定 |
Map和Set
二叉搜索树的概念,当我们中序遍历它的时候,就可以得到一个有序的从小到大的一组数.
AVL树: 也就是二叉平衡树,这个树是基于二叉搜索树的,它主要是来解决二叉搜索树出现单分支情况,而导致树的高度太高了.解决方式是左旋或者右旋(左右高度差大于等于2就进行).这样就把树的高度给平衡了.
红黑树: 它是基于AVL树进行改良的,给每个节点加上了颜色,然后可以减少左旋右旋的次数来平衡高度.
map和set整体框架介绍
Set是基于Map实现的
map和set的结构
map里面存储的是key-val键值对(K一定是唯一的,不能重复.).比如:单词-该单词搜索的次数
set里面只存储了key(key一定要唯一).比如:快速查找某个名字在不在通讯录中
TreeMap和HashMap的区别
Map底层结构 | TreeMap | HashMap |
底层结构 | 红黑树 | 哈希桶 |
插入/删除/查找时间复杂度 | O(logn) | O(1) |
是否有序 | 关于Key有序 | 无序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 需要对元素进行比较 | 需要通过哈希函数计算哈希地址 |
比较和覆盖 | key必须能够进行比较,否则会抛出ClassCastException异常 | 自定义类型需要覆盖和重写equals和hashCode方法 |
应用场景 | 需要Key有序的情况下 | Key是否有序无所谓,主要是搜索的更快 |
TreeSet和HashSet的区别
Set底层结构 | TreeSet | HashSet |
底层结构 | 红黑树 | 哈希桶 |
插入/删除/查找时间复杂度 | O(logn) | O(1) |
是否有序 | 关于key有序 | 不一定有序 |
线程安全 | 不安全 | 不安全 |
插入/删除/查找区别 | 按照红黑树的特性来进行插入和删除 | 1. 先计算key哈希地址 2. 进行插入和删除 |
比较和覆写 | Key必须能够进行比较,否则会抛出ClassCastException | 自定义类型需要覆盖hashCode方法 |
应用场景 | 需要Key有序场景下 | Key是否有序并不关系,搜索速度够快 |
哈希表
实现O(1)的查询
不经过任何比较,一次直接从表中得到要搜索的元素。 如果构造一种存储结构,通过某种函数(hashFunc)使元素的存储位置与它的关键码之间能够建立一一映射的关系,那么在查找时通过该函数可以很快找到该元素。
我们因此设计了哈希表.哈希表是一种由数组+链表+红黑树组成的数据结构.
插入元素
根据插入元素的关键码,以此函数计算出该元素的存储位置并把元素存放到这个位置上.
搜索元素
对元素的关键码进行同样的计算,把求得的函数值当作元素的存储位置,在结构中按此位置取元素进行比较,如果关键码相等,则搜索成功.
哈希方法中使用的转换函数称为哈希(散列)函数,构造出来的结构称为哈希表(Hash Table)(或者称散列表)
用该方法进行搜索不必进行多次关键码的比较,因此搜索的速度比较快
hash冲突以及解决方式
当不同关键码通过同一个哈希函数计算出来同一个hash地址,这个就是哈希冲突
解决方式: 冲突的发生是必然的,但我们能做的应该是尽量的降低冲突率
1> 调节负载因子
负载因子: a = 填入表中的元素个数 / 散列表的长度
根据这个公式,如果我们需要降低负载因子的大小,我们只能增加散列表的长度,不能够减小元素个数.
负载因子越大说明产生冲突的概率就越大, java内部负载因子长度为0.75,超过了这个值,就会resize散列表("扩容",但是并不是单纯就扩大数组长度,后面实现hash表的时候会具体解释)
2> 闭散列
也叫开放定址法,当发生哈希冲突时,如果哈希表未被装满,说明在哈希表中必然还有空位置,那么可以把key存放到冲突位置中的“下一个” 空位置中去。下面介绍找到空位置的方法
1.线性探测
从发生冲突的位置开始,依次向后探测,直到寻找到下一个空位置为止。
缺点: 产生冲突的元素都聚在一起了
2. 二次探测
(i表示冲突次数)Hi = (H0 + i^2) % m,产生冲突的元素就分散开来了
3> 哈希桶
哈希桶: 由数组+链表+红黑树所组成的数据结构
注意: 数组长度大于64并且链表长度大于8的时候就会把链表树化成红黑树
解决冲突:
1. 每个桶的背后是另一个哈希表
2. 每个桶的背后是一棵搜索树
哈希桶的底层实现->把结点放进数组
进行扩容操作:
array = Arrays.copyOf(array,2 * array.length);仅仅进行这样的扩容操作是否有问题呢?(面试题)
答案是肯定的,因为我们扩容之后用的是扩容后的长度进行%,因此放的位置肯定不同,因此需要冲洗hash.
步骤:
1. 先产生一个新的数组(大小是原来的二倍)
2. 遍历原来的数组元素(找每个下标是否连接链表)
3. 根据链表元素和新的哈希函数,我们计算出要插入的新数组的位置
4. 然后我们进行头插法
hash桶和hash表的区别
HashCode的应用--String进阶
池的概念:
我们先得知道什么是池,池这个东西就是存着我们经常用的一种容器,是为了实现随去随用的,提高运行速度节省内存的容器.
字符串常量池: 字符串常量池在JVM中是StringTable类,实际是一个固定大小的HashTable,java8里面,字符串常量池在堆里面.
这里注意,只要是""引起来的都会放到字符串常量池中.
重点:
1. 创建字符串的俩种方法在虚拟机和堆上是怎么创建的
2. intern方法把字符串手动放进字符串常量池
intern
intern 是一个native方法(Native方法指:底层使用C++实现的,看不到其实现的源代码),该方法的作用是手动将创建的String对象添加到常量池中
不加intern
加了intern
反射机制
反射是一种在运行状态下就能知道任意一个类的属性和方法(包括私有的)的一种机制.对于任意一个对象,都能够调用它的任意方法和属性,既然能拿到那么,我们就可以修改部分类型信息;这种动态获取信息以及动态调用对象方法的功能称为java语言的反射(reflection)机制.
关于运行时和编译时
java程序中许多对象在运行时会出现两种类型:运行时类型(RTTI)和编译时类型,例如Person p = new Student();这句代码中p在编译时类型为Person,运行时类型为Student。程序需要在运行时发现对象和类的真实信息。而通过使用反射程序就能判断出该对象和类属于哪些类.
这种动态获取的信息以及动态调用对象的方法的功能称为Java语言的反射机制
class类
Java文件在被编译之后生成了.class文件 , JVM把.class解析为一个对象,这个对象就是Class类对象(java.lang.Class).
1. 获得class对象 class.forName(类路径)
方式
1)Class.forName(“类的路径”);
2)类名.class
3)对象名.getClass()
4)基本类型的包装类,可以调用包装类的Type属性来获得该包装类的Class对象
2. 获取类的实例 类对象.getDeclaredConstrucetor().newInstance()
3. 获取方法 实例.getMethod(方法名称)
4. 调用方法 方法名.invoke(实例)
优点: 1)能够运行时动态获取类的实例,提高灵活性; 2)与动态编译结合 缺点: 1)使用反射性能较低,需要解析字节码,将内存中的对象进行解析。 解决方案: 1、通过setAccessible(true)关闭JDK的安全检查来提升反射速度; 2、多次创建一个类的实例时,有缓存会快很多 3、ReflectASM工具类,通过字节码生成的方式加快反射速度 2)相对不安全,破坏了封装性(因为通过反射可以获得私有方法和属性)
应用: JDBC连接数据库
枚举类型是一种组织常量的数据类型
优点: 把常量组织起来统一进行管理
应用: 错误状态码,颜色的划分,状态机...
枚举不能反射
Lambda表达式
(parameters) -> expression或者(parameters)->{statements;}
1> paramaters: 相当于方法中的形参列表(可以省去类型,jvm会自己推断出来).当只有一个推断类型的时候可以省去"()".
2> -> :这个是被应用于的意思
3> 方法体: 相当于函数式接口里面方法的实现,也就是方法体,可以返回值也可以不反回值.
函数式接口:一个接口有且只有一个抽象方法.一般都会在这个接口上声明@FunctionInterface.
lambda表达式的基本使用:
Lambda可以理解为:Lambda就是匿名内部类的简化,实际上是创建了一个类,实现了接口,重写了接口的方法 。
匿名内部类:是Java中一种特殊的内部类,它没有名字,通常用于实现接口或继承类的实例,并且只使用一次。它是一个简化类定义的方式,常用于临时的实现,特别是在需要传递一个类的实例给方法时。
使用匿名内部类和lambda表达式
变量捕获:只要被匿名内部类里面的函数所使用的变量就不能被修改lambda也如此.
因此在匿名内部类里面所使用的变量:1. 要么是常量 2.要么是没有被修改过的变量
Lambda表达式最大的优点就是代码会变的非常的简洁,但是于此同时的缺点就是代码可读性差,补容易进行调试.
通配符: ?
? 用于在泛型的使用,即为通配符
?表示可以接收所有的泛型类型,但是又不能够让用户随意修改。
小结:
1. ?一般作为参数类型使用,T一般用于泛型类,泛型方法的定义来使用.
2. ? 一般不晓得确切类型,因此只能读不能写(修改).T一般晓得确切类型,可以读和修改.
3. 通配符的下界,不能进行读取数据,只能写入数据。通配符的上界,不能进行写入数据,只能进行读取数据。