java字符串详解
一、String
类的定义
public final class String implements java.io.Serializable, Comparable<String>, CharSequence
String是一个final类,不能被继承的类
String类实现了java.io.Serializable接口,可以实现序列化
String类实现了Comparable<String>,可以用于比较大小(按顺序比较单个字符的ASCII码)
String类实现了 CharSequence 接口,表示是一个有序字符的序列,因为String的本质是一个char类型数组
1、字段属性
//用来存字符串,字符串的本质,是一个final的char型数组 private final char value[]; //缓存字符串的哈希,是String实例化的hashcode的一个缓存。因为String经常被用于比较,比如在HashMap中。如果每次进行比较都重新计算hashcode的值的话,那无疑是比较麻烦的,而保存一个hashcode的缓存无疑能优化这样的操作 private int hash; // Default to 0 //实现序列化的标识 private static final long serialVersionUID = -6849794470754667710L;
2、构造函数
String类有很多个构造函数,包括接收String,char[],byte[],StringBuffer等多种参数类型,其本质实现就是把参数的值根据需求(例如,起始位置,个数等)赋值给value[]变量。下面例举一部分:
/** 01 * 这是一个经常会使用的String的无参构造函数. * 默认将""空字符串的value赋值给实例对象的value,也是空字符 * 相当于深拷贝了空字符串"" */ public String() { this.value = "".value; } /** 02 * 这是一个有参构造函数,参数为一个String对象 * 将形参的value和hash赋值给实例对象作为初始化 * 相当于深拷贝了一个形参String对象 */ public String(String original) { this.value = original.value; this.hash = original.hash; } /** 03 * 这是一个有参构造函数,参数为一个char字符数组 * 意义就是通过字符数组去构建一个新的String对象 */ public String(char value[]) { this.value = Arrays.copyOf(value, value.length); } /** 04 * 这是一个有参构造函数,参数为char字符数组,offset(起始位置,偏移量),count(个数) * 作用就是在char数组的基础上,从offset位置开始计数count个,构成一个新的String的字符串 * 意义就类似于截取count个长度的字符集合构成一个新的String对象 */ public String(char value[], int offset, int count) { if (offset < 0) { //如果起始位置小于0,抛异常 throw new StringIndexOutOfBoundsException(offset); } if (count <= 0) { if (count < 0) { //如果个数小于0,抛异常 throw new StringIndexOutOfBoundsException(count); } if (offset <= value.length) { //在count = 0的前提下,如果offset<=len,则返回"" this.value = "".value; return; } } // Note: offset or count might be near -1>>>1. //如果起始位置>字符数组长度 - 个数,则无法截取到count个字符,抛异常 if (offset > value.length - count) { throw new StringIndexOutOfBoundsException(offset + count); } //重点,从offset开始,截取到offset+count位置(不包括offset+count位置) this.value = Arrays.copyOfRange(value, offset, offset+count); }
3、length和isEmpty
public int length() { return value.length; } public boolean isEmpty() { return value.length == 0; }
知道string底层是char数组之后,其实这两个方法及时调用数据的长度。
4、charAt、codePointAt
/** * 返回String对象的char数组index位置的元素 */ public char charAt(int index) { if ((index < 0) || (index >= value.length)) { //index不允许小于0,不允许大于等于String的长度 throw new StringIndexOutOfBoundsException(index); } return value[index]; //就是返回数组指定下标的值 } /** * 返回String对象的char数组index位置的元素的ASSIC码(int类型) *例如:a=”abc” ; a. codePointAt(1) =>返回b对应的ASSIC值98 */ public int codePointAt(int index) { if ((index < 0) || (index >= value.length)) { throw new StringIndexOutOfBoundsException(index); } return Character.codePointAtImpl(value, index, value.length); } /** * 返回index位置元素的前一个元素的ASSIC码(int型) */ public int codePointBefore(int index) { int i = index - 1; //获得index前一个元素的索引位置 if ((i < 0) || (i >= value.length)) { //所以,index不能等于0,因为i = 0 - 1 = -1 throw new StringIndexOutOfBoundsException(index); } return Character.codePointBeforeImpl(value, index, 0); } /** * 方法返回的是代码点个数,是实际上的字符个数,功能类似于length() * 对于正常的String来说,length方法和codePointCount没有区别,都是返回字符个数。 * 但当String是Unicode类型时则有区别了。 * 例如:String str = “/uD835/uDD6B” (即使 'Z' ), length() = 2 ,codePointCount() = 1 */ public int codePointCount(int beginIndex, int endIndex) { if (beginIndex < 0 || endIndex > value.length || beginIndex > endIndex) { throw new IndexOutOfBoundsException(); } return Character.codePointCountImpl(value, beginIndex, endIndex - beginIndex); } /** * 也是相对Unicode字符集而言的,从index索引位置算起,偏移codePointOffset个位置,返回偏移后的位置是多少 * 例如,index = 2 ,codePointOffset = 3 ,maybe返回 5 */ public int offsetByCodePoints(int index, int codePointOffset) { if (index < 0 || index > value.length) { throw new IndexOutOfBoundsException(); } return Character.offsetByCodePointsImpl(value, 0, value.length, index, codePointOffset); }
5、getChar、getBytes
/** * 这是一个不对外的方法,是给String内部调用的,因为它是默认访问修饰符的,只允许同一包下的类访问 * 参数:dst[]是目标数组,dstBegin是目标数组的偏移量,既要复制过去的起始位置(从目标数组的什么位置覆盖) * 作用就是将String的字符数组value整个复制到dst字符数组中,在dst数组的dstBegin位置开始拷贝 * */ void getChars(char dst[], int dstBegin) { System.arraycopy(value, 0, dst, dstBegin, value.length); } /** * 得到char字符数组,原理是getChars() 方法将一个字符串的字符复制到目标字符数组中。 * 参数:srcBegin是原始字符串的起始位置,srcEnd是原始字符串要复制的字符末尾的后一个位置(既复制区域不包括srcEnd) * dst[]是目标字符数组,dstBegin是目标字符的复制偏移量,复制的字符从目标字符数组的dstBegin位置开始覆盖。 */ public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) { if (srcBegin < 0) { //如果srcBegin小于,抛异常 throw new StringIndexOutOfBoundsException(srcBegin); } * if (srcEnd > value.length) { //如果srcEnd大于字符串的长度,抛异常 throw new StringIndexOutOfBoundsException(srcEnd); } if (srcBegin > srcEnd) { //如果原始字符串其实位置大于末尾位置,抛异常 throw new StringIndexOutOfBoundsException(srcEnd - srcBegin); } System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin); } /** * 获得charsetName编码格式的bytes数组 */ public byte[] getBytes(String charsetName) throws UnsupportedEncodingException { if (charsetName == null) throw new NullPointerException(); return StringCoding.encode(charsetName, value, 0, value.length); } /** * 与上个方法类似(底层实现不一样),指定字符集编码 */ public byte[] getBytes(Charset charset) { if (charset == null) throw new NullPointerException(); return StringCoding.encode(charset, value, 0, value.length); } /** * 使用平台默认的编码格式ISO-8859-1获得bytes数组 */ public byte[] getBytes() { return StringCoding.encode(value, 0, value.length); }
6、equal、equalsIgnoreCase等
这个方法是重写了Object类的equal方法,Object中的实现是简单的判断两个对象是否是同一个对象来确定两个对象是否相等。
public boolean equals(Object anObject) { //首先判断是否是同一个对象 if (this == anObject) { return true; } if (anObject instanceof String) { //判断是否是String类 String anotherString = (String)anObject; int n = value.length; if (n == anotherString.value.length) { //判断长度 char v1[] = value; char v2[] = anotherString.value; int i = 0; while (n-- != 0) { //一次判断同一个位置的字符是否相等 if (v1[i] != v2[i]) return false; i++; } return true; } } return false; } //不区分大小写的判断 public boolean equalsIgnoreCase(String anotherString) { return (this == anotherString) ? true //一样,先判断是否为同一个对象 : (anotherString != null) && (anotherString.value.length == value.length) //再判断长度是否相等 && regionMatches(true, 0, anotherString, 0, value.length); //再执行regionMatchs方法 } public boolean regionMatches(boolean ignoreCase, int toffset, String other, int ooffset, int len) { char ta[] = value; int to = toffset; char pa[] = other.value; int po = ooffset; if ((ooffset < 0) || (toffset < 0) //判断参数 || (toffset > (long)value.length - len) || (ooffset > (long)other.value.length - len)) { return false; } while (len-- > 0) { //指定长度判断相同位置字符是否相等 char c1 = ta[to++]; char c2 = pa[po++]; if (c1 == c2) { continue; //如果相等,则继续循环,如果不相等,则继续往下执行 } if (ignoreCase) { //判断是否需要忽略大小写 char u1 = Character.toUpperCase(c1); //转大写进行判断 char u2 = Character.toUpperCase(c2); if (u1 == u2) { continue; } if (Character.toLowerCase(u1) == Character.toLowerCase(u2)) {//转小写进行判断 continue; } } return false; } return true; } ** * 这是一个私有方法,特供给比较StringBuffer和StringBuilder使用的。 * 比如在contentEquals方法中使用,参数是AbstractStringBuilder抽象类的子类 * */ private boolean nonSyncContentEquals(AbstractStringBuilder sb) { char v1[] = value; //当前String对象的值 char v2[] = sb.getValue(); //AbstractStringBuilder子类对象的值 int n = v1.length; //后面就不说了,其实跟equals方法是一样的,只是少了一些判断 if (n != sb.length()) { return false; } for (int i = 0; i < n; i++) { if (v1[i] != v2[i]) { return false; } } return true; } /** * 这是一个判断范围的比较广的方法,参数是StringBuffer类型 * 实际调用的是contentEquals(CharSequence cs)方法,可以说是StringBuffer的特供版 */ public boolean contentEquals(StringBuffer sb) { return contentEquals((CharSequence)sb); } public boolean contentEquals(CharSequence cs) { // Argument is a StringBuffer, StringBuilder if (cs instanceof AbstractStringBuilder) { //如果是AbstractStringBuilder抽象类或其子类 if (cs instanceof StringBuffer) { //如果是StringBuffer类型,进入同步块 synchronized(cs) { return nonSyncContentEquals((AbstractStringBuilder)cs); } } else { //如果是StringBuilder类型,则进入非同步块 return nonSyncContentEquals((AbstractStringBuilder)cs); } } /***下面就是String和CharSequence类型的比较算法*****/ // Argument is a String if (cs instanceof String) { return equals(cs); } // Argument is a generic CharSequence char v1[] = value; int n = v1.length; if (n != cs.length()) { return false; } for (int i = 0; i < n; i++) { if (v1[i] != cs.charAt(i)) { return false; } } return true; }
1、equals()方法作为常用的方法,很具有层次感和借鉴意义,首先判断是否为同一个对象,再判断是否为要比较的类型,再判断两个对象的长度是否相等,首先从广的角度过滤筛选不符合的对象,再符合条件的对象基础上再一个一个字符的比较。
2、equalsIgnoreCase()方法是对equals()方法补充,不区分大小写的判断
3、contentEquals()则是用于String对象与4种类型的判断,通常用于跟StringBuilder和StringBuffer的判断,也是对equals方法的一个补充。
7、compareTo
在compareTo方法讲解前,先说说一个静态内部类CaseInsensitiveComparator,在String中已经有了一个compareTo的方法,为什么还要有一个CaseInsensitiveComparator的内部静态类呢?其实这一切都是为了代码复用。首先看一下这个类就会发现,其实这个比较和compareTo方法也是有差别的,这个方法在比较时是忽略大小写的。而且这是一个单例,可以简单得用它来比较两个String,因为String类提供一个变量:CASE_INSENSITIVE_ORDER 来持有这个内部类,这样当要比较两个String时可以通过这个变量来调用。其次,可以看到String类中提供的compareToIgnoreCase方法其实就是调用这个内部类里面的方法实现的。这就是代码复用的一个例子。
//持有CaseInsensitiveComparator静态类的单例 public static final Comparator<String> CASE_INSENSITIVE_ORDER = new CaseInsensitiveComparator(); private static class CaseInsensitiveComparator implements Comparator<String>, java.io.Serializable { // use serialVersionUID from JDK 1.2.2 for interoperability private static final long serialVersionUID = 8575799808933029326L; public int compare(String s1, String s2) { int n1 = s1.length(); int n2 = s2.length(); int min = Math.min(n1, n2); for (int i = 0; i < min; i++) { char c1 = s1.charAt(i); char c2 = s2.charAt(i); if (c1 != c2) { c1 = Character.toUpperCase(c1); c2 = Character.toUpperCase(c2); if (c1 != c2) { c1 = Character.toLowerCase(c1); c2 = Character.toLowerCase(c2); if (c1 != c2) { // No overflow because of numeric promotion return c1 - c2; } } } } return n1 - n2; } private Object readResolve() { return CASE_INSENSITIVE_ORDER; } } public int compareToIgnoreCase(String str) { return CASE_INSENSITIVE_ORDER.compare(this, str); } public int compareTo(String anotherString) { int len1 = value.length; //当前对象的长度 int len2 = anotherString.value.length; //比较对象的长度 int lim = Math.min(len1, len2); //获得最小长度 char v1[] = value; //获得当前对象的值 char v2[] = anotherString.value; //获得比较对象的值 int k = 0; //相当于for的int k = 0,就是为while循环的数组服务的 while (k < lim) { //当当前索引小于两个字符串中较短字符串的长度时,循环继续 char c1 = v1[k]; //获得当前对象的字符 char c2 = v2[k]; //获得比较对象的字符 if (c1 != c2) { //从前向后遍历,只要其实一个不相等,返回字符ASSIC的差值,int类型 return c1 - c2; } k++; } return len1 - len2; //如果两个字符串同样位置的索引都相等,返回长度差值,完全相等则为0 }
8、startWith、endWith
/** * 作用就是当前对象[toffset,toffset + prefix.value.lenght]区间的字符串片段等于prefix * 也可以说当前对象的toffset位置开始是否以prefix作为前缀 * prefix是需要判断的前缀字符串,toffset是当前对象的判断起始位置 */ public boolean startsWith(String prefix, int toffset) { char ta[] = value; //获得当前对象的值 int to = toffset; //获得需要判断的起始位置,偏移量 char pa[] = prefix.value; //获得前缀字符串的值 int po = 0; int pc = prefix.value.length; // Note: toffset might be near -1>>>1. if ((toffset < 0) || (toffset > value.length - pc)) { //偏移量不能小于0且能截取pc个长度 return false; //不能则返回false } while (--pc >= 0) { //循环pc次,既prefix的长度 if (ta[to++] != pa[po++]) { //每次比较当前对象的字符串的字符是否跟prefix一样 return false; //一样则pc--,to++,po++,有一个不同则返回false } } return true; //没有不一样则返回true,当前对象是以prefix在toffset位置做为开头 } /** * 判断当前字符串对象是否以字符串prefix起头 * 是返回true,否返回fasle */ public boolean startsWith(String prefix) { return startsWith(prefix, 0); } /** * 判断当前字符串对象是否以字符串prefix结尾 * 是返回true,否返回fasle */ public boolean endsWith(String suffix) { //suffix是需要判断是否为尾部的字符串。 //value.length - suffix.value.length是suffix在当前对象的起始位置 return startsWith(suffix, value.length - suffix.value.length); }
9、hashCode
/** * 这是String字符串重写了Object类的hashCode方法。 * 给由哈希表来实现的数据结构来使用,比如String对象要放入HashMap中。 * 如果没有重写HashCode,或HaseCode质量很差则会导致严重的后果,既不靠谱的后果 */ public int hashCode() { int h = hash; //hash是属性字段,是成员变量,所以默认为0 if (h == 0 && value.length > 0) { //如果hash为0,且字符串对象长度大于0,不为"" char val[] = value; //获得当前对象的值 //重点,String的哈希函数 for (int i = 0; i < value.length; i++) { //遍历len次 h = 31 * h + val[i]; //每次都是31 * 每次循环获得的h +第i个字符的ASSIC码 } hash = h; } return h; //由此可见""空字符对象的哈希值为0 }
10、substring
subString的原理是通过String的构造函数实现的
/** * 截取当前字符串对象的片段,组成一个新的字符串对象 * beginIndex为截取的初始位置,默认截到len - 1位置 */ public String substring(int beginIndex) { if (beginIndex < 0) { //小于0抛异常 throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = value.length - beginIndex; //新字符串的长度 if (subLen < 0) { //小于0抛异常 throw new StringIndexOutOfBoundsException(subLen); } //如果beginIndex是0,则不用截取,返回自己(非新对象),否则截取0到subLen位置,不包括(subLen) return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); } /** * 截取一个区间范围 * [beginIndex,endIndex),不包括endIndex */ public String substring(int beginIndex, int endIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } if (endIndex > value.length) { throw new StringIndexOutOfBoundsException(endIndex); } int subLen = endIndex - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } return ((beginIndex == 0) && (endIndex == value.length)) ? this : new String(value, beginIndex, subLen); } public CharSequence subSequence(int beginIndex, int endIndex) { return this.substring(beginIndex, endIndex); }
11、concat
public String concat(String str) { int otherLen = str.length();//获得参数字符串的长度 if (otherLen == 0) { //如果长度为0,则代表不需要拼接,因为str为"" return this; } int len = value.length; //获得当前对象的长度 //将数组扩容,将value数组拷贝到buf数组中,长度为len + str.lenght char buf[] = Arrays.copyOf(value, len + otherLen); str.getChars(buf, len); //然后将str字符串从buf字符数组的len位置开始覆盖,得到一个完整的buf字符数组 return new String(buf, true);//构建新的String对象,调用私有的String构造方法 }
12、replace、replaceAll
//替换,将字符串中的oldChar字符全部替换成newChar public String replace(char oldChar, char newChar) { if (oldChar != newChar) { //如果旧字符不等于新字符的情况下 int len = value.length; //获得字符串长度 int i = -1; //flag char[] val = value; /* avoid getfield opcode */ while (++i < len) { //循环len次 if (val[i] == oldChar) { //找到第一个旧字符,打断循环 break; } } if (i < len) { //如果第一个旧字符的位置小于len char buf[] = new char[len]; 新new一个字符数组,len个长度 for (int j = 0; j < i; j++) { buf[j] = val[j]; 把旧字符的前面的字符都复制到新字符数组上 } while (i < len) { //从i位置开始遍历 char c = val[i]; buf[i] = (c == oldChar) ? newChar : c; //发生旧字符就替换,不想关的则直接复制 i++; } return new String(buf, true); //通过新字符数组buf重构一个新String对象 } } return this; //如果old = new ,直接返回自己 } //替换第一个旧字符 String replaceFirst(String regex, String replacement) { return Pattern.compile(regex).matcher(this).replaceFirst(replacement); } //当不是正规表达式时,与replace效果一样,都是全体换。如果字符串的正则表达式,则规矩表达式全体替换 public String replaceAll(String regex, String replacement) { return Pattern.compile(regex).matcher(this).replaceAll(replacement); } //可以用旧字符串去替换新字符串 public String replace(CharSequence target, CharSequence replacement) { return Pattern.compile(target.toString(), Pattern.LITERAL).matcher( this).replaceAll(Matcher.quoteReplacement(replacement.toString())); }
从replace的算法中,我们可以发现,它不是从头开始遍历替换的,而是首先找到第一个要替换的字符,从要替换的字符开始遍历,为啥不一开始就遍历呢?这样的好处就是如果没有找到旧的字符,这样就可以不用创建新的char数组了,返回原来的就好了。
13、matches()和contains()
public boolean matches(String regex) { return Pattern.matches(regex, this); //实际使用的是Pattern.matches()方法 } //是否含有CharSequence这个子类元素,通常用于StrngBuffer,StringBuilder public boolean contains(CharSequence s) { return indexOf(s.toString()) > -1; }
14、其他方法
Split:分割
Join:拼接
Trim:去除空格
toString:返回string
toCharArray:返回char数组
toLowerCase:小写
toUpperCase:大写
format:指定字符串格式
valueOf:转字符串
intern:本地方法,作用就是去字符串常量池中寻找str字符串,如果有则返回str在常量池中的引用,如果没有则在常量池中创建str对象
15、数组复制方法
在字符串的底层都是数组的操作,就会存在数组的复制问题,然后就是arraycopy方法和copyOf方法了,接下来看看这两个的区别
System类(也是final修饰):不会创建新的数组
public static native void arraycopy(Object src, int srcPos, Object dest, int destPos, int length);//本地方法
Arrays类:会创建一个新的数组,底层调用System.arraycopy方法
public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) { @SuppressWarnings("unchecked") T[] copy = ((Object)newType == (Object)Object[].class) ? (T[]) new Object[newLength] : (T[]) Array.newInstance(newType.getComponentType(), newLength); System.arraycopy(original, 0, copy, 0, Math.min(original.length, newLength)); return copy; }
二、AbstractStringBuilder
1、抽象类的定义
abstract class AbstractStringBuilder implements Appendable, CharSequence
2、字段属性
char[] value; int count; //字符使用的长度
3、构造函数
AbstractStringBuilder() { } AbstractStringBuilder(int capacity) { value = new char[capacity]; }
4、长度和容量
public int length() { //表示有多少个字符 return count; } public int capacity() { //表示数组(字符串)的最大容量 return value.length; }
5、append
public AbstractStringBuilder append(Object obj) { return append(String.valueOf(obj)); // 转为字符串 } public AbstractStringBuilder append(String str) { if (str == null) return appendNull(); //添加null int len = str.length(); ensureCapacityInternal(count + len); //确保value数组够大 str.getChars(0, len, value, count); //复制 count += len; return this; } //添加null private AbstractStringBuilder appendNull() { int c = count; ensureCapacityInternal(c + 4); final char[] value = this.value; value[c++] = 'n'; value[c++] = 'u'; value[c++] = 'l'; value[c++] = 'l'; count = c; return this; } private void ensureCapacityInternal(int minimumCapacity) { // overflow-conscious code if (minimumCapacity - value.length > 0) { //count+str.length是否大于数组的容量 value = Arrays.copyOf(value, newCapacity(minimumCapacity)); // 扩容 } } private int newCapacity(int minCapacity) { // overflow-conscious code int newCapacity = (value.length << 1) + 2; //将value数组的容量扩大为原来的两倍+2 if (newCapacity - minCapacity < 0) { //对比扩大之后的容量与count+length的大小 newCapacity = minCapacity; } return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0) //返回新数组容量 ? hugeCapacity(minCapacity) //Integer.MAX_VALUE - 8 : newCapacity; } //是的count==length,节约数组空间 public void trimToSize() { if (count < value.length) { value = Arrays.copyOf(value, count); } }
6、setCharAt
//由于value没有想String声明为final,所以可以进行修改 public void setCharAt(int index, char ch) { if ((index < 0) || (index >= count)) throw new StringIndexOutOfBoundsException(index); value[index] = ch; }
三、StringBuffer
public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, CharSequence
继承了AbstractStringBuilder的所有方法,方法实现基本都是调用父类的方法,在外层封装同步,线程安全
四、StringBuilder
public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, CharSequence
继承了AbstractStringBuilder的所有方法,方法实现基本都是调用父类的方法,非线程安全
五、String、StringBuffer,StringBuilder三者的区别和使用
1.从是否可变的角度:
String类中使用字符数组保存字符串,因为有“final”修饰符,所以String对象是不可变的
StringBuffer和StringBuilder都继承自AbstractStringBuilder类,在AbstractStringBuilder中也是使用字符数组保存字符串,但没有“final”修饰符,所以两种对象都是可变的
2.是否多线程安全:
String中的对象是不可变的,也就可以理解为常量,所以是线程安全的。
StringBuffer对方法加了同步锁(synchronized) ,所以是线程安全的
StringBuilder并没有对方法进行加同步锁,所以是非线程安全的,效率最高
3、使用
如果你要求字符串不可变,那么应该选择String类
如果你需要字符串可变并且是线程安全的,那么你应该选择StringBuffer类
如果你要求字符串可变并且不存在线程安全问题,那么你应该选择StringBuilder类
参考:
https://blog.csdn.net/snailmann/article/details/80882719
https://www.cnblogs.com/jasonboren/p/11053044.html
http://blog.itpub.net/31543790/viewspace-2220506/
出处:https://www.cnblogs.com/zsql/
如果您觉得阅读本文对您有帮助,请点击一下右下方的推荐按钮,您的推荐将是我写作的最大动力!
版权声明:本文为博主原创或转载文章,欢迎转载,但转载文章之后必须在文章页面明显位置注明出处,否则保留追究法律责任的权利。